feat(v4): ask-ai foundations ✨ (#2574)
This commit is contained in:
parent
e84ae91abb
commit
5ab4d3d215
28 changed files with 783 additions and 186 deletions
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
16
examples/demo-askai/index.html
Normal file
16
examples/demo-askai/index.html
Normal 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>
|
||||
23
examples/demo-askai/package.json
Normal file
23
examples/demo-askai/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
examples/demo-askai/public/favicon.ico
Normal file
BIN
examples/demo-askai/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
4
examples/demo-askai/src/App.css
Normal file
4
examples/demo-askai/src/App.css
Normal 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';
|
||||
}
|
||||
23
examples/demo-askai/src/App.tsx
Normal file
23
examples/demo-askai/src/App.tsx
Normal 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;
|
||||
11
examples/demo-askai/src/main.tsx
Normal file
11
examples/demo-askai/src/main.tsx
Normal 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
1
examples/demo-askai/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
31
examples/demo-askai/tsconfig.json
Normal file
31
examples/demo-askai/tsconfig.json
Normal 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"
|
||||
],
|
||||
}
|
||||
7
examples/demo-askai/vite.config.ts
Normal file
7
examples/demo-askai/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
41
packages/docsearch-react/src/AskAiScreen.tsx
Normal file
41
packages/docsearch-react/src/AskAiScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
26
packages/docsearch-react/src/icons/BackIcon.tsx
Normal file
26
packages/docsearch-react/src/icons/BackIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ interface DocSearchContext extends AutocompleteContext {
|
|||
|
||||
export interface DocSearchState<TItem extends BaseItem> extends AutocompleteState<TItem> {
|
||||
context: DocSearchContext;
|
||||
isAskAiActive: boolean;
|
||||
}
|
||||
|
|
|
|||
0
packages/docsearch-react/src/useAskAi.ts
Normal file
0
packages/docsearch-react/src/useAskAi.ts
Normal 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]);
|
||||
}
|
||||
|
|
|
|||
13
yarn.lock
13
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue