1
0
Fork 0

feat(v4): ask-ai foundations (#2574)

This commit is contained in:
Dylan Tientcheu 2025-04-28 08:41:27 +02:00 committed by GitHub
parent e84ae91abb
commit 5ab4d3d215
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 783 additions and 186 deletions

View file

@ -6,11 +6,11 @@
},
{
"path": "packages/docsearch-react/dist/umd/index.js",
"maxSize": "27 kB"
"maxSize": "30 kB"
},
{
"path": "packages/docsearch-js/dist/umd/index.js",
"maxSize": "35.5 kB"
"maxSize": "37 kB"
}
]
}

View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocSearch v4 - AskAI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
{
"name": "@docsearch/askai-example",
"description": "DocSearch v4 AskAI example",
"version": "3.9.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@docsearch/css": "3.9.0",
"@docsearch/react": "3.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View file

@ -0,0 +1,4 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}

View file

@ -0,0 +1,23 @@
/* eslint-disable react/react-in-jsx-scope */
import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
import './App.css';
import '@docsearch/css/dist/style.css';
function App(): JSX.Element {
return (
<div>
<h1>DocSearch v4 - AskAI</h1>
<DocSearch
indexName="beta-react"
appId="betaHAXPMHIMMC"
apiKey="8b00405cba281a7d800ccec393e9af24"
datasourceId="crawler_rag_beta-react-rag"
promptId="crawler_rag_beta-react-rag"
insights={true}
/>
</div>
);
}
export default App;

View file

@ -0,0 +1,11 @@
/* eslint-disable react/react-in-jsx-scope */
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

1
examples/demo-askai/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src",
"vite.config.ts"
],
}

View file

@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});

View file

@ -1,6 +1,6 @@
{
"name": "@docsearch/react-example",
"description": "DocSearch v3 React example",
"description": "DocSearch v4 React example",
"version": "3.9.0",
"private": true,
"license": "MIT",

View file

@ -20,6 +20,7 @@
"lint": "eslint .",
"playground:build": "yarn workspace @docsearch/react-example build",
"playground:start": "yarn workspace @docsearch/react-example dev --host",
"playground-askai:start": "yarn workspace @docsearch/askai-example dev --host",
"playground-js:start": "yarn workspace @docsearch/js-example dev --host",
"release": "shipjs prepare",
"start": "yarn run watch",

View file

@ -30,6 +30,11 @@
stroke-width: 1.6;
}
.DocSearch-Back-Icon {
color: var(--docsearch-highlight-color);
stroke-width: 1.6;
}
.DocSearch-Button-Placeholder {
font-size: 1.25rem;
font-weight: 300;

View file

@ -117,15 +117,21 @@
.DocSearch-LoadingIndicator,
.DocSearch-MagnifierLabel,
.DocSearch-AskAi-Return,
.DocSearch-Close {
margin: 0;
padding: 0;
}
.DocSearch-AskAi-Return {
cursor: pointer;
}
.DocSearch-MagnifierLabel,
.DocSearch-AskAi-Return,
.DocSearch-Close {
align-items: center;
color: var(--docsearch-highlight-color) !important;
color: var(--docsearch-highlight-color);
display: flex;
justify-content: center;
}
@ -159,7 +165,8 @@
}
}
.DocSearch-Close {
.DocSearch-Close,
.DocSearch-AskAi-Return {
animation: fade-in 0.1s ease-in forwards;
appearance: none;
background: none;
@ -172,21 +179,28 @@
stroke-width: var(--docsearch-icon-stroke-width);
}
.DocSearch-Close[hidden] {
.DocSearch-Close[hidden],
.DocSearch-AskAi-Return[hidden] {
display: none;
}
.DocSearch-Close:hover {
.DocSearch-Close:hover,
.DocSearch-AskAi-Return:hover {
color: var(--docsearch-highlight-color);
}
.DocSearch-LoadingIndicator svg,
.DocSearch-MagnifierLabel svg {
color: var(--docsearch-highlight-color) !important;
color: var(--docsearch-icon-color);
height: 24px;
width: 24px;
}
/* highlight icon color when search input is focused */
.DocSearch-Form:focus-within .DocSearch-MagnifierLabel svg {
color: var(--docsearch-highlight-color);
}
.DocSearch-Clear {
appearance: none;
background: none;
@ -328,6 +342,10 @@
scroll-margin-top: 40px;
}
.DocSearch-Hit:first-of-type {
margin-top: 4px;
}
@media screen and (prefers-reduced-motion: reduce) {
.DocSearch-Hit--deleting {
transition: none;
@ -352,12 +370,14 @@
transition-delay: 250ms;
}
.DocSearch-Hit a {
.DocSearch-Hit a,
.DocSearch-Hit--AskAI {
background: var(--docsearch-hit-background);
border-radius: 4px;
display: block;
padding-left: var(--docsearch-spacing);
width: 100%;
cursor: pointer;
}
.DocSearch-Hit-source {
@ -381,7 +401,8 @@
width: 24px;
}
.DocSearch-Hit[aria-selected='true'] a {
.DocSearch-Hit[aria-selected='true'] a,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit--AskAI {
background-color: var(--docsearch-hit-highlight-color) !important;
}
@ -490,13 +511,13 @@ svg.DocSearch-Hit-Select-Icon {
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-title,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-text,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-Tree {
color: var(--docsearch-text-color) !important;
color: var(--docsearch-text-color);
}
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-action,
.DocSearch-Hit[aria-selected='true'] mark,
.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-icon {
color: var(--docsearch-highlight-color) !important;
color: var(--docsearch-highlight-color);
}
@media screen and (prefers-reduced-motion: reduce) {
@ -511,7 +532,8 @@ svg.DocSearch-Hit-Select-Icon {
.DocSearch-NoResults,
.DocSearch-StartScreen,
.DocSearch-ErrorScreen {
.DocSearch-ErrorScreen,
.DocSearch-AskAiScreen {
font-size: 1.25em;
font-weight: 400;
margin: 0 auto;
@ -525,7 +547,8 @@ svg.DocSearch-Hit-Select-Icon {
}
.DocSearch-NoResults,
.DocSearch-ErrorScreen {
.DocSearch-ErrorScreen,
.DocSearch-AskAiScreen {
height: 80%;
}
@ -538,7 +561,8 @@ svg.DocSearch-Hit-Select-Icon {
}
.DocSearch-StartScreen,
.DocSearch-ErrorScreen {
.DocSearch-ErrorScreen,
.DocSearch-AskAiScreen {
gap: 24px;
}
@ -666,6 +690,53 @@ assistive tech users */
width: 1px;
}
/* Docsearch Result Actions */
.DocSearch-AskAi-Section {
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 8px;
}
/* AskAI Button */
.DocSearch-Hit-AskAIButton {
color: var(--docsearch-text-color);
display: flex;
align-items: center;
flex-direction: row;
}
.DocSearch-Hit-AskAIButton-icon {
color: var(--docsearch-icon-color);
margin-right: 12px;
flex-shrink: 0;
}
.DocSearch-Hit-AskAIButton-title {
display: flex;
flex: 1 1 auto;
font-weight: 500;
line-height: 1.2em;
overflow-x: hidden;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
width: 80%;
gap: 4px;
color: var(--docsearch-hit-color);
}
.DocSearch-Hit-AskAIButton-title-query {
white-space: nowrap;
font-weight: 400;
overflow: hidden;
margin-left: 4px;
text-overflow: ellipsis;
}
/* Responsive */
@media (max-width: 768px) {
:root {
@ -673,6 +744,10 @@ assistive tech users */
--docsearch-footer-height: 48px;
}
.DocSearch-Hit-AskAIButton-icon {
margin-right: 8px;
}
.DocSearch-Dropdown {
height: 100%;
}

View file

@ -0,0 +1,41 @@
import React, { type JSX } from 'react';
export type AskAiScreenTranslations = Partial<{
titleText: string;
helpText: string;
}>;
type AskAiScreenProps = {
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;
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>
<p className="DocSearch-Title">{titleText}</p>
<p className="DocSearch-Help">{helpText}</p>
</div>
);
}

View file

@ -26,6 +26,8 @@ export interface DocSearchProps {
appId: string;
apiKey: string;
indexName: string;
datasourceId?: string;
promptId?: string;
placeholder?: string;
searchParameters?: SearchParamsObject;
maxResultsPerGroup?: number;
@ -45,6 +47,17 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
const searchButtonRef = React.useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const [initialQuery, setInitialQuery] = React.useState<string | undefined>(props?.initialQuery || undefined);
const [isAskAiActive, setIsAskAiActive] = React.useState(false);
// check if the instance is configured to handle ask ai
const canHandleAskAi = Boolean(props?.datasourceId && props?.promptId);
const onAskAiToggle = React.useCallback(
(askAitoggle: boolean) => {
setIsAskAiActive(askAitoggle);
},
[setIsAskAiActive],
);
const onOpen = React.useCallback(() => {
setIsOpen(true);
@ -68,6 +81,8 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
onOpen,
onClose,
onInput,
isAskAiActive,
onAskAiToggle,
searchButtonRef,
});
@ -82,6 +97,9 @@ export function DocSearch(props: DocSearchProps): JSX.Element {
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}
isAskAiActive={isAskAiActive}
canHandleAskAi={canHandleAskAi}
onAskAiToggle={onAskAiToggle}
onClose={onClose}
/>,
document.body,

View file

@ -1,4 +1,9 @@
import { type AlgoliaInsightsHit, createAutocomplete } from '@algolia/autocomplete-core';
import {
type AutocompleteSource,
type AlgoliaInsightsHit,
createAutocomplete,
type AutocompleteState,
} from '@algolia/autocomplete-core';
import type { SearchResponse } from 'algoliasearch/lite';
import React, { type JSX } from 'react';
@ -26,10 +31,217 @@ export type ModalTranslations = Partial<{
export type DocSearchModalProps = DocSearchProps & {
initialScrollY: number;
onAskAiToggle: (toggle: boolean) => void;
onClose?: () => void;
isAskAiActive?: boolean;
canHandleAskAi?: boolean;
translations?: ModalTranslations;
};
/**
* Helper function to build sources when there is no query
* useful for recent searches and favorite searches.
*/
const buildNoQuerySources = (
recentSearches: ReturnType<typeof createStoredSearches>,
favoriteSearches: ReturnType<typeof createStoredSearches>,
saveRecentSearch: (item: InternalDocSearchHit) => void,
onClose: () => void,
disableUserPersonalization: boolean,
): Array<AutocompleteSource<InternalDocSearchHit>> => {
if (disableUserPersonalization) {
return [];
}
return [
{
sourceId: 'recentSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return recentSearches.getAll() as InternalDocSearchHit[];
},
},
{
sourceId: 'favoriteSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return favoriteSearches.getAll() as InternalDocSearchHit[];
},
},
];
};
type BuildQuerySourcesState = Pick<AutocompleteState<InternalDocSearchHit>, 'context'>;
/**
* Helper function to build sources when there is a query
* note: we only need specific parts of the state, not the full DocSearchState.
*/
const buildQuerySources = async ({
query,
state: sourcesState,
setContext,
setStatus,
searchClient,
indexName,
searchParameters,
snippetLength,
insights,
appId,
apiKey,
maxResultsPerGroup,
transformItems = identity, // default to identity if not provided
saveRecentSearch,
onClose,
}: {
query: string;
state: BuildQuerySourcesState;
setContext: (context: Partial<DocSearchState<InternalDocSearchHit>['context']>) => void;
setStatus: (status: DocSearchState<InternalDocSearchHit>['status']) => void;
searchClient: ReturnType<typeof useSearchClient>;
indexName: string;
searchParameters: DocSearchProps['searchParameters'];
snippetLength: React.MutableRefObject<number>;
insights: boolean; // ensure boolean
appId?: string;
apiKey?: string;
maxResultsPerGroup?: number;
transformItems?: DocSearchProps['transformItems']; // prop can be undefined
saveRecentSearch: (item: InternalDocSearchHit) => void;
onClose: () => void;
}): Promise<Array<AutocompleteSource<InternalDocSearchHit>>> => {
const insightsActive = insights; // already boolean
try {
const { results } = await searchClient.search<DocSearchHit>({
requests: [
{
query,
indexName,
attributesToRetrieve: [
'hierarchy.lvl0',
'hierarchy.lvl1',
'hierarchy.lvl2',
'hierarchy.lvl3',
'hierarchy.lvl4',
'hierarchy.lvl5',
'hierarchy.lvl6',
'content',
'type',
'url',
],
attributesToSnippet: [
`hierarchy.lvl1:${snippetLength.current}`,
`hierarchy.lvl2:${snippetLength.current}`,
`hierarchy.lvl3:${snippetLength.current}`,
`hierarchy.lvl4:${snippetLength.current}`,
`hierarchy.lvl5:${snippetLength.current}`,
`hierarchy.lvl6:${snippetLength.current}`,
`content:${snippetLength.current}`,
],
snippetEllipsisText: '…',
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
hitsPerPage: 20,
clickAnalytics: insightsActive,
...searchParameters,
},
],
});
const firstResult = results[0] as SearchResponse<DocSearchHit>;
const { hits, nbHits } = firstResult;
const sources = groupBy<DocSearchHit>(hits, (hit) => removeHighlightTags(hit), maxResultsPerGroup);
// We store the `lvl0`s to display them as search suggestions
// in the "no results" screen.
if ((sourcesState.context.searchSuggestions as any[]).length < Object.keys(sources).length) {
setContext({
searchSuggestions: Object.keys(sources),
});
}
setContext({ nbHits });
let insightsParams = {};
if (insightsActive) {
insightsParams = {
__autocomplete_indexName: indexName,
__autocomplete_queryID: firstResult.queryID,
__autocomplete_algoliaCredentials: {
appId,
apiKey,
},
};
}
return Object.values<DocSearchHit[]>(sources).map((items, index) => {
return {
sourceId: `hits${index}`,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
.map(transformItems)
.map((groupedHits) =>
groupedHits.map((item) => {
let parent: InternalDocSearchHit | null = null;
const potentialParent = groupedHits.find(
(siblingItem) => siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1,
) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
}),
)
.flat();
},
};
});
} catch (error) {
// The Algolia `RetryError` happens when all the servers have
// failed, meaning that there's no chance the response comes
// back. This is the right time to display an error.
// See https://github.com/algolia/algoliasearch-client-javascript/blob/2ffddf59bc765cd1b664ee0346b28f00229d6e12/packages/transporter/src/errors/createRetryError.ts#L5
if ((error as Error).name === 'RetryError') {
setStatus('error');
}
throw error;
}
};
export function DocSearchModal({
appId,
apiKey,
@ -49,6 +261,9 @@ export function DocSearchModal({
translations = {},
getMissingResultsUrl,
insights = false,
onAskAiToggle,
isAskAiActive = false,
canHandleAskAi = false,
}: DocSearchModalProps): JSX.Element {
const { footer: footerTranslations, searchBox: searchBoxTranslations, ...screenStateTranslations } = translations;
const [state, setState] = React.useState<DocSearchState<InternalDocSearchHit>>({
@ -59,6 +274,7 @@ export function DocSearchModal({
isOpen: false,
activeItemId: null,
status: 'idle',
isAskAiActive,
});
const containerRef = React.useRef<HTMLDivElement | null>(null);
@ -129,7 +345,8 @@ export function DocSearchModal({
createAutocomplete<InternalDocSearchHit, React.FormEvent<HTMLFormElement>, React.MouseEvent, React.KeyboardEvent>(
{
id: 'docsearch',
defaultActiveItemId: 0,
// we don't want to focus on the AskAI hit by default
defaultActiveItemId: canHandleAskAi ? 1 : 0,
placeholder,
openOnFocus: true,
initialState: {
@ -138,171 +355,107 @@ export function DocSearchModal({
searchSuggestions: [],
},
},
insights,
insights: Boolean(insights),
navigator,
onStateChange(props) {
setState(props.state);
const nextState = props.state;
setState((prevState) => {
// to avoid flickering, we ignore the update from autocomplete-core
// when the query just went empty, status is idle, collections are empty,
// and we weren't already loading/stalled.
const queryWentEmpty = prevState.query !== '' && nextState.query === '';
const statusIsIdle = nextState.status === 'idle';
const collectionsAreEmpty = !(nextState.collections?.some((c) => c.items.length > 0) ?? false);
const wasNotLoading = prevState.status !== 'loading' && prevState.status !== 'stalled';
if (queryWentEmpty && statusIsIdle && collectionsAreEmpty && wasNotLoading) {
return prevState;
}
// otherwise, merge state as usual
return {
...prevState,
...nextState,
};
});
},
getSources({ query, state: sourcesState, setContext, setStatus }) {
if (!query) {
if (disableUserPersonalization) {
return [];
}
return [
{
sourceId: 'recentSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return recentSearches.getAll() as InternalDocSearchHit[];
},
},
{
sourceId: 'favoriteSearches',
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return favoriteSearches.getAll() as InternalDocSearchHit[];
},
},
];
return buildNoQuerySources(
recentSearches,
favoriteSearches,
saveRecentSearch,
onClose,
disableUserPersonalization,
);
}
const insightsActive = Boolean(insights);
const querySourcesState: BuildQuerySourcesState = { context: sourcesState.context };
return searchClient
.search<DocSearchHit>({
requests: [
// Algolia sources
const algoliaSourcesPromise = buildQuerySources({
query,
state: querySourcesState,
setContext,
setStatus,
searchClient,
indexName,
searchParameters,
snippetLength,
insights: Boolean(insights),
appId,
apiKey,
maxResultsPerGroup,
transformItems,
saveRecentSearch,
onClose,
});
// AskAI source
const askAiSource: Array<AutocompleteSource<InternalDocSearchHit>> = canHandleAskAi
? [
{
query,
indexName,
attributesToRetrieve: [
'hierarchy.lvl0',
'hierarchy.lvl1',
'hierarchy.lvl2',
'hierarchy.lvl3',
'hierarchy.lvl4',
'hierarchy.lvl5',
'hierarchy.lvl6',
'content',
'type',
'url',
],
attributesToSnippet: [
`hierarchy.lvl1:${snippetLength.current}`,
`hierarchy.lvl2:${snippetLength.current}`,
`hierarchy.lvl3:${snippetLength.current}`,
`hierarchy.lvl4:${snippetLength.current}`,
`hierarchy.lvl5:${snippetLength.current}`,
`hierarchy.lvl6:${snippetLength.current}`,
`content:${snippetLength.current}`,
],
snippetEllipsisText: '…',
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
hitsPerPage: 20,
clickAnalytics: insightsActive,
...searchParameters,
},
],
})
.catch((error) => {
// The Algolia `RetryError` happens when all the servers have
// failed, meaning that there's no chance the response comes
// back. This is the right time to display an error.
// See https://github.com/algolia/algoliasearch-client-javascript/blob/2ffddf59bc765cd1b664ee0346b28f00229d6e12/packages/transporter/src/errors/createRetryError.ts#L5
if (error.name === 'RetryError') {
setStatus('error');
}
throw error;
})
.then(({ results }) => {
const firstResult = results[0] as SearchResponse<DocSearchHit>;
const { hits, nbHits } = firstResult;
const sources = groupBy<DocSearchHit>(hits, (hit) => removeHighlightTags(hit), maxResultsPerGroup);
// We store the `lvl0`s to display them as search suggestions
// in the "no results" screen.
if ((sourcesState.context.searchSuggestions as any[]).length < Object.keys(sources).length) {
setContext({
searchSuggestions: Object.keys(sources),
});
}
setContext({ nbHits });
let insightsParams = {};
if (insightsActive) {
insightsParams = {
__autocomplete_indexName: indexName,
__autocomplete_queryID: firstResult.queryID,
__autocomplete_algoliaCredentials: {
appId,
apiKey,
sourceId: 'askAI',
getItems(): InternalDocSearchHit[] {
// return a single item representing the Ask AI action
// placeholder data matching the InternalDocSearchHit structure
const askItem: InternalDocSearchHit = {
type: 'askAI',
query,
// placeholders (dummy data)
url_without_anchor: '',
objectID: `ask-ai-button`,
content: null,
url: '',
anchor: null,
hierarchy: {
lvl0: 'Ask AI', // Or contextually relevant
lvl1: query,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
_highlightResult: {} as any,
_snippetResult: {} as any,
__docsearch_parent: null,
};
return [askItem];
},
};
}
return Object.values<DocSearchHit[]>(sources).map((items, index) => {
return {
sourceId: `hits${index}`,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
onClose();
onSelect({ item }): void {
if (item.type === 'askAI') {
onAskAiToggle(true);
}
},
getItemUrl({ item }): string {
return item.url;
},
getItems(): InternalDocSearchHit[] {
return Object.values(groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup))
.map(transformItems)
.map((groupedHits) =>
groupedHits.map((item) => {
let parent: InternalDocSearchHit | null = null;
},
]
: [];
const potentialParent = groupedHits.find(
(siblingItem) =>
siblingItem.type === 'lvl1' && siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1,
) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
}),
)
.flat();
},
};
});
});
// Combine Algolia results (once resolved) with the Ask AI source
return algoliaSourcesPromise.then((algoliaSources) => {
return [...askAiSource, ...algoliaSources];
});
},
},
),
@ -312,8 +465,6 @@ export function DocSearchModal({
maxResultsPerGroup,
searchClient,
onClose,
recentSearches,
favoriteSearches,
saveRecentSearch,
initialQuery,
placeholder,
@ -323,6 +474,10 @@ export function DocSearchModal({
insights,
appId,
apiKey,
favoriteSearches,
recentSearches,
canHandleAskAi,
onAskAiToggle,
],
);
@ -441,7 +596,9 @@ export function DocSearchModal({
inputRef={inputRef}
isFromSelection={Boolean(initialQuery) && initialQuery === initialQueryFromSelection}
translations={searchBoxTranslations}
isAskAiActive={isAskAiActive}
onClose={onClose}
onAskAiToggle={onAskAiToggle}
/>
</header>
@ -458,7 +615,16 @@ export function DocSearchModal({
inputRef={inputRef}
translations={screenStateTranslations}
getMissingResultsUrl={getMissingResultsUrl}
isAskAiActive={isAskAiActive}
canHandleAskAi={canHandleAskAi}
onAskAiToggle={onAskAiToggle}
onItemClick={(item, event) => {
// if the item is askAI, do nothing
if (item.type === 'askAI') {
onAskAiToggle(true);
return;
}
// If insights is active, send insights click event
sendItemClickEvent(item);
@ -471,7 +637,7 @@ export function DocSearchModal({
</div>
<footer className="DocSearch-Footer">
<Footer translations={footerTranslations} />
<Footer translations={footerTranslations} isAskAiActive={isAskAiActive} />
</footer>
</div>
</div>

View file

@ -4,17 +4,20 @@ import { AlgoliaLogo } from './AlgoliaLogo';
export type FooterTranslations = Partial<{
selectText: string;
submitQuestionText: string;
selectKeyAriaLabel: string;
navigateText: string;
navigateUpKeyAriaLabel: string;
navigateDownKeyAriaLabel: string;
closeText: string;
backToSearchText: string;
closeKeyAriaLabel: string;
poweredByText: string;
}>;
type FooterProps = Partial<{
translations: FooterTranslations;
isAskAiActive: boolean;
}>;
interface CommandIconProps {
@ -32,14 +35,16 @@ function CommandIcon(props: CommandIconProps): JSX.Element {
);
}
export function Footer({ translations = {} }: FooterProps): JSX.Element {
export function Footer({ translations = {}, isAskAiActive = false }: FooterProps): JSX.Element {
const {
selectText = 'Select',
selectKeyAriaLabel = 'Enter key',
submitQuestionText = 'Submit question',
navigateText = 'Navigate',
navigateUpKeyAriaLabel = 'Arrow up',
navigateDownKeyAriaLabel = 'Arrow down',
closeText = 'Close',
backToSearchText = 'Back to search',
closeKeyAriaLabel = 'Escape key',
poweredByText = 'Powered by',
} = translations;
@ -72,14 +77,14 @@ export function Footer({ translations = {} }: FooterProps): JSX.Element {
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</CommandIcon>
</kbd>
<span className="DocSearch-Label">{selectText}</span>
<span className="DocSearch-Label">{isAskAiActive ? submitQuestionText : selectText}</span>
</li>
<li>
<kbd className="DocSearch-Commands-Key">
<span className="DocSearch-Escape-Key">ESC</span>
</kbd>
<span className="DocSearch-Label" aria-label={closeKeyAriaLabel}>
{closeText}
{isAskAiActive ? backToSearchText : closeText}
</span>
</li>
</ul>

View file

@ -5,9 +5,13 @@ import type { DocSearchProps } from './DocSearch';
import { Snippet } from './Snippet';
import type { InternalDocSearchHit, StoredDocSearchHit } from './types';
export type ResultsTranslations = Partial<{
askAiPlaceholder: string;
}>;
interface ResultsProps<TItem extends BaseItem>
extends AutocompleteApi<TItem, React.FormEvent, React.MouseEvent, React.KeyboardEvent> {
title: string;
translations?: ResultsTranslations;
collection: AutocompleteState<TItem>['collections'][0];
renderIcon: (props: { item: TItem; index: number }) => React.ReactNode;
renderAction: (props: {
@ -24,6 +28,16 @@ export function Results<TItem extends StoredDocSearchHit>(props: ResultsProps<TI
return null;
}
if (props.collection.source.sourceId === 'askAI') {
return (
<section className="DocSearch-AskAi-Section">
<ul {...props.getListProps({ source: props.collection.source })}>
<AskAiResult item={props.collection.items[0]} translations={props.translations} {...props} />
</ul>
</section>
);
}
return (
<section className="DocSearch-Hits">
<div className="DocSearch-Hit-source">{props.title}</div>
@ -126,3 +140,62 @@ function Result<TItem extends StoredDocSearchHit>({
</li>
);
}
interface AskAiResultProps<TItem extends BaseItem> extends ResultsProps<TItem> {
item: TItem;
translations?: ResultsTranslations;
}
function AskAiResult<TItem extends StoredDocSearchHit>({
item,
getItemProps,
onItemClick,
translations,
collection,
}: AskAiResultProps<TItem>): JSX.Element {
const { askAiPlaceholder = 'Ask AI: ' } = translations || {};
const 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>
);
return (
<li
className="DocSearch-Hit"
{...getItemProps({
item,
source: collection.source,
onClick(event) {
onItemClick(item, event);
},
})}
>
<div className="DocSearch-Hit--AskAI">
<div className="DocSearch-Hit-AskAIButton DocSearch-Hit-Container">
<div className=" DocSearch-Hit-AskAIButton-icon DocSearch-Hit-icon">{icon}</div>
<div className="DocSearch-Hit-AskAIButton-title">
<span className="DocSearch-Hit-AskAIButton-title-highlight">{askAiPlaceholder}</span>
<span className="DocSearch-Hit-AskAIButton-title-query">"{item.query || ''}"</span>
</div>
</div>
</div>
</li>
);
}

View file

@ -6,9 +6,15 @@ import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types';
import { removeHighlightTags } from './utils';
type ResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'>;
export type ResultsScreenTranslations = Partial<{
askAiPlaceholder: string;
}>;
export function ResultsScreen(props: ResultsScreenProps): JSX.Element {
type ResultsScreenProps = Omit<ScreenStateProps<InternalDocSearchHit>, 'translations'> & {
translations?: ResultsScreenTranslations;
};
export function ResultsScreen({ translations = {}, ...props }: ResultsScreenProps): JSX.Element {
return (
<div className="DocSearch-Dropdown-Container">
{props.state.collections.map((collection) => {
@ -22,6 +28,7 @@ export function ResultsScreen(props: ResultsScreenProps): JSX.Element {
<Results
{...props}
key={collection.source.sourceId}
translations={translations}
title={title}
collection={collection}
renderIcon={({ item, index }) => (

View file

@ -1,11 +1,14 @@
import type { AutocompleteApi, AutocompleteState, BaseItem } from '@algolia/autocomplete-core';
import React from 'react';
import type { AskAiScreenTranslations } from './AskAiScreen';
import { AskAiScreen } from './AskAiScreen';
import type { DocSearchProps } from './DocSearch';
import type { ErrorScreenTranslations } from './ErrorScreen';
import { ErrorScreen } from './ErrorScreen';
import type { NoResultsScreenTranslations } from './NoResultsScreen';
import { NoResultsScreen } from './NoResultsScreen';
import type { ResultsScreenTranslations } from './ResultsScreen';
import { ResultsScreen } from './ResultsScreen';
import type { StartScreenTranslations } from './StartScreen';
import { StartScreen } from './StartScreen';
@ -16,6 +19,8 @@ export type ScreenStateTranslations = Partial<{
errorScreen: ErrorScreenTranslations;
startScreen: StartScreenTranslations;
noResultsScreen: NoResultsScreenTranslations;
resultsScreen: ResultsScreenTranslations;
askAiScreen: AskAiScreenTranslations;
}>;
export interface ScreenStateProps<TItem extends BaseItem>
@ -24,6 +29,9 @@ export interface ScreenStateProps<TItem extends BaseItem>
recentSearches: StoredSearchPlugin<StoredDocSearchHit>;
favoriteSearches: StoredSearchPlugin<StoredDocSearchHit>;
onItemClick: (item: InternalDocSearchHit, event: KeyboardEvent | MouseEvent) => void;
onAskAiToggle: (toggle: boolean) => void;
isAskAiActive: boolean;
canHandleAskAi: boolean;
inputRef: React.MutableRefObject<HTMLInputElement | null>;
hitComponent: DocSearchProps['hitComponent'];
indexName: DocSearchProps['indexName'];
@ -35,7 +43,11 @@ export interface ScreenStateProps<TItem extends BaseItem>
export const ScreenState = React.memo(
({ translations = {}, ...props }: ScreenStateProps<InternalDocSearchHit>) => {
if (props.state.status === 'error') {
if (props.isAskAiActive && props.canHandleAskAi) {
return <AskAiScreen translations={translations?.askAiScreen} />;
}
if (props.state?.status === 'error') {
return <ErrorScreen translations={translations?.errorScreen} />;
}
@ -45,11 +57,19 @@ export const ScreenState = React.memo(
return <StartScreen {...props} hasCollections={hasCollections} translations={translations?.startScreen} />;
}
if (hasCollections === false) {
if (hasCollections === false && !props.canHandleAskAi) {
return <NoResultsScreen {...props} translations={translations?.noResultsScreen} />;
}
return <ResultsScreen {...props} />;
return (
<>
<ResultsScreen {...props} translations={translations?.resultsScreen} />
{props.canHandleAskAi && props.state.collections.length === 1 && (
// if there's one collection it is the ask ai action, show the no results screen
<NoResultsScreen {...props} translations={translations?.noResultsScreen} />
)}
</>
);
},
function areEqual(_prevProps, nextProps) {
// We don't update the screen when Autocomplete is loading or stalled to

View file

@ -3,6 +3,7 @@ import React, { type JSX, type RefObject } from 'react';
import { MAX_QUERY_SIZE } from './constants';
import { LoadingIcon, CloseIcon, SearchIcon } from './icons';
import { BackIcon } from './icons/BackIcon';
import type { InternalDocSearchHit } from './types';
export type SearchBoxTranslations = Partial<{
@ -11,6 +12,8 @@ export type SearchBoxTranslations = Partial<{
closeButtonText: string;
closeButtonAriaLabel: string;
searchInputLabel: string;
backToKeywordSearchButtonText: string;
backToKeywordSearchButtonAriaLabel: string;
}>;
interface SearchBoxProps
@ -19,6 +22,8 @@ interface SearchBoxProps
autoFocus: boolean;
inputRef: RefObject<HTMLInputElement | null>;
onClose: () => void;
onAskAiToggle: (toggle: boolean) => void;
isAskAiActive: boolean;
isFromSelection: boolean;
translations?: SearchBoxTranslations;
}
@ -30,6 +35,8 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
closeButtonText = 'Close',
closeButtonAriaLabel = 'Close',
searchInputLabel = 'Search',
backToKeywordSearchButtonText = 'Back to keyword search',
backToKeywordSearchButtonAriaLabel = 'Back to keyword search',
} = translations;
const { onReset } = props.getFormProps({
inputElement: props.inputRef.current,
@ -56,10 +63,23 @@ export function SearchBox({ translations = {}, ...props }: SearchBoxProps): JSX.
}}
onReset={onReset}
>
<label className="DocSearch-MagnifierLabel" {...props.getLabelProps()}>
<SearchIcon />
<span className="DocSearch-VisuallyHiddenForAccessibility">{searchInputLabel}</span>
</label>
{props.isAskAiActive ? (
<button
type="button"
tabIndex={0}
className="DocSearch-AskAi-Return"
title={backToKeywordSearchButtonText}
aria-label={backToKeywordSearchButtonAriaLabel}
onClick={() => props.onAskAiToggle(false)}
>
<BackIcon />
</button>
) : (
<label className="DocSearch-MagnifierLabel" {...props.getLabelProps()}>
<SearchIcon />
<span className="DocSearch-VisuallyHiddenForAccessibility">{searchInputLabel}</span>
</label>
)}
<div className="DocSearch-LoadingIndicator">
<LoadingIcon />

View file

@ -0,0 +1,26 @@
import React, { type JSX } from 'react';
interface BackIconProps {
size?: number;
color?: string;
}
export function BackIcon({ size = 20, color = 'currentColor' }: BackIconProps): JSX.Element {
return (
<svg
width={size}
height={size}
className="DocSearch-Back-Icon"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
);
}

View file

@ -1,4 +1,4 @@
type ContentType = 'content' | 'lvl0' | 'lvl1' | 'lvl2' | 'lvl3' | 'lvl4' | 'lvl5' | 'lvl6';
type ContentType = 'askAI' | 'content' | 'lvl0' | 'lvl1' | 'lvl2' | 'lvl3' | 'lvl4' | 'lvl5' | 'lvl6';
interface DocSearchHitAttributeHighlightResult {
value: string;
@ -37,6 +37,7 @@ interface DocSearchHitSnippetResult {
export declare type DocSearchHit = {
objectID: string;
content: string | null;
query?: string;
url: string;
url_without_anchor: string;
type: ContentType;

View file

@ -13,4 +13,5 @@ interface DocSearchContext extends AutocompleteContext {
export interface DocSearchState<TItem extends BaseItem> extends AutocompleteState<TItem> {
context: DocSearchContext;
isAskAiActive: boolean;
}

View file

View file

@ -6,6 +6,8 @@ export interface UseDocSearchKeyboardEventsProps {
onClose: () => void;
onInput?: (event: KeyboardEvent) => void;
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
isAskAiActive: boolean;
onAskAiToggle: (toggle: boolean) => void;
}
function isEditingContent(event: KeyboardEvent): boolean {
@ -20,10 +22,17 @@ export function useDocSearchKeyboardEvents({
onOpen,
onClose,
onInput,
isAskAiActive,
onAskAiToggle,
searchButtonRef,
}: UseDocSearchKeyboardEventsProps): void {
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (isOpen && event.code === 'Escape' && isAskAiActive) {
onAskAiToggle(false);
return;
}
if (
(event.code === 'Escape' && isOpen) ||
// The `Cmd+K` shortcut both opens and closes the modal.
@ -60,5 +69,5 @@ export function useDocSearchKeyboardEvents({
return (): void => {
window.removeEventListener('keydown', onKeyDown);
};
}, [isOpen, onOpen, onClose, onInput, searchButtonRef]);
}, [isOpen, onOpen, onClose, onInput, searchButtonRef, isAskAiActive, onAskAiToggle]);
}

View file

@ -2089,6 +2089,19 @@ __metadata:
languageName: node
linkType: hard
"@docsearch/askai-example@workspace:examples/demo-askai":
version: 0.0.0-use.local
resolution: "@docsearch/askai-example@workspace:examples/demo-askai"
dependencies:
"@docsearch/css": "npm:3.9.0"
"@docsearch/react": "npm:3.9.0"
"@vitejs/plugin-react": "npm:^4.3.4"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
vite: "npm:^6.0.7"
languageName: unknown
linkType: soft
"@docsearch/css@npm:3.9.0, @docsearch/css@workspace:*, @docsearch/css@workspace:packages/docsearch-css":
version: 0.0.0-use.local
resolution: "@docsearch/css@workspace:packages/docsearch-css"