1
0
Fork 0

feat(v5): UI and DX improvements (#2949)

* feat: Rename assistantId to agentId

* feat: Allow reading default facet values from index searchParameters

* feat: Remove indexName prop from Sidepanel, cleanup documentation pages

* feat: Move appId and apiKey up into @docsearch/core

* feat: Add back nested grouping of search results

* add changeset

* revert changes to example demo

* fix: e2e tests
This commit is contained in:
Paul Jankowski 2026-08-04 09:43:08 -04:00 committed by GitHub
parent 5eac1fd6bb
commit 4f6b5b1b88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 781 additions and 283 deletions

View file

@ -0,0 +1,15 @@
---
"@docsearch/docusaurus-adapter": patch
"@docsearch/sidepanel": patch
"@docsearch/modal": patch
"@docsearch/react": patch
"@docsearch/core": patch
---
feat(v5): UI and DX updates
- Rename the Ask AI assistantId option to agentId (adapter theme.SearchModal.askAi.assistantId → agentId)
- appId and apiKey moved up into @docsearch/core, so they're configured once and shared
- Removed the indexName prop from the Sidepanel
- Facet defaults can now be read from the index searchParameters
- Restored nested grouping of search results

View file

@ -117,7 +117,6 @@
"new-cap": ["error"],
"no-array-constructor": ["error"],
"no-bitwise": ["error"],
"no-continue": ["error"],
"no-lonely-if": ["error"],
"no-nested-ternary": ["error"],
"no-unneeded-ternary": ["error"],

View file

@ -23,7 +23,7 @@ const minimalDocSearchConfig = {
} satisfies DocSearchInput;
const minimalAskAiConfig = {
assistantId: 'my-assistant-id',
agentId: 'my-assistant-id',
} satisfies NonNullable<DocSearchInput>['askAi'];
const askAiConfigWithIndices = {

View file

@ -47,7 +47,7 @@ declare module '@docsearch/docusaurus-adapter' {
};
export type AskAiConfig = {
assistantId: DocSearchAskAi['assistantId'];
agentId: DocSearchAskAi['agentId'];
suggestedQuestions?: DocSearchAskAi['suggestedQuestions'];
searchParameters?: AgentStudioSearchParameters;
indices?: AgentStudioIndices[];

View file

@ -79,6 +79,8 @@ type AdapterDocSearchProps = Omit<
| 'onAskAiToggle'
| 'onClose'
> & {
appId: DocSearchProps['appId'];
apiKey: DocSearchProps['apiKey'];
askAi?: AskAiOptions;
contextualSearch?: boolean;
externalUrlRegex?: string;
@ -422,10 +424,9 @@ function DocSearch({
{...panelOptions}
variant={panelOptions.variant ?? 'inline'}
pushSelector={panelOptions.pushSelector ?? '#__docusaurus'}
assistantId={sidePanelAskAi.assistantId}
agentId={sidePanelAskAi.agentId}
apiKey={sidePanelAskAi.apiKey}
appId={sidePanelAskAi.appId}
indexName={sidePanelAskAi.indexName}
searchParameters={sidePanelAskAi.searchParameters}
indices={panelOptions.indices ?? sidePanelAskAi.indices}
suggestedQuestions={
@ -454,6 +455,8 @@ export default function SearchBar(
return (
<DocSearchProvider
appId={docSearchProps.appId}
apiKey={docSearchProps.apiKey}
initialQuery={docSearchProps.initialQuery}
keyboardShortcuts={docSearchProps.keyboardShortcuts}
>

View file

@ -131,7 +131,7 @@ const AskAiPromptSuggestionsSchema = Joi.object({
}).unknown(false);
const AskAiSchema = Joi.object({
assistantId: Joi.string().required(),
agentId: Joi.string().required(),
suggestedQuestions: Joi.boolean().optional(),
searchParameters: Joi.object()
.pattern(Joi.string(), SearchParametersSchema)
@ -261,7 +261,7 @@ function assertNoRemovedKeys(themeConfig: ThemeConfig): void {
const askAi = docsearchRecord.askAi;
if (typeof askAi === 'string') {
throw new Error(
'`themeConfig.docsearch.askAi` must be an object with `assistantId`.'
'`themeConfig.docsearch.askAi` must be an object with `agentId`.'
);
}

View file

@ -30,7 +30,7 @@ test.describe('a11y > Modal', () => {
await expect(docSearch.hits).toBeVisible();
const scanResults = await axe()
.include('#docsearch-hits_docsearch-list')
.include('#docsearch-hits_docsearch_0-list')
.analyze();
await testInfo.attach('a11y-scan-results-modal-search-results', {

View file

@ -24,7 +24,7 @@ export class DocSearchPage {
this.hits = page.locator('.DocSearch-Hits').first();
this.clearButton = page.locator('.DocSearch-Clear');
this.firstHit = page
.locator('#docsearch-hits_docsearch-list .DocSearch-Hit a')
.locator('#docsearch-hits_docsearch_0-list .DocSearch-Hit a')
.first();
}

View file

@ -113,7 +113,7 @@ test.describe('Search', () => {
await docSearch.typeQueryMatching();
await expect(docSearch.hits).toBeVisible();
const firstHitOption = page
.locator('#docsearch-hits_docsearch-list')
.locator('#docsearch-hits_docsearch_0-list')
.getByRole('option')
.first();
const firstHitOptionId = await firstHitOption.getAttribute('id');

View file

@ -36,10 +36,9 @@ let sidepanelInstance: SidepanelInstance | undefined = undefined;
sidepanelInstance = sidepanel({
container: '#docsearch-sidepanel',
indexName: 'docsearch',
appId: 'PMZUYBQDAK',
apiKey: '24b09689d5b4223813d9b8e48563c8f6',
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
agentId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
onReady: () => {
// eslint-disable-next-line no-console
console.log('[demo-js] sidepanel onReady()');
@ -73,9 +72,7 @@ docsearchInstance = docsearch({
indices: ['docsearch'],
appId: 'PMZUYBQDAK',
apiKey: '24b09689d5b4223813d9b8e48563c8f6',
askAi: {
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
},
askAi: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
interceptAskAiEvent: (initialMessage) => {
docsearchInstance?.close();
sidepanelInstance.open(initialMessage);

View file

@ -0,0 +1,4 @@
export const APP_ID = 'PMZUYBQDAK';
export const API_KEY = '24b09689d5b4223813d9b8e48563c8f6';
export const AGENT_ID = 'ccdec697-e3fe-465b-a1c3-657e7bf18aef';
export const SEARCH_INDEX_NAME = 'docsearch';

View file

@ -3,6 +3,7 @@ import { DocSearchAI, type ToolCalls } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
const customTools: ToolCalls = {
printConsoleMessage: {
@ -29,11 +30,11 @@ export default function BasicAskAI({
}): JSX.Element {
return (
<DocSearchAI
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
indices={[SEARCH_INDEX_NAME]}
appId={APP_ID}
apiKey={API_KEY}
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
agentId: AGENT_ID,
suggestedQuestions: true,
tools: customTools,
promptSuggestions: {

View file

@ -3,13 +3,14 @@ import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function Basic({ theme }: { theme: DemoTheme }): JSX.Element {
return (
<DocSearch
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
indices={[SEARCH_INDEX_NAME]}
appId={APP_ID}
apiKey={API_KEY}
translations={{ button: { buttonText: 'Keyword search' } }}
insights={true}
theme={theme}

View file

@ -4,6 +4,7 @@ import { DocSearchButton, DocSearchAskAiModal } from '@docsearch/modal';
import { type JSX } from 'react';
import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function Composable({
theme,
@ -11,16 +12,9 @@ export default function Composable({
theme: DemoTheme;
}): JSX.Element {
return (
<DocSearch theme={theme}>
<DocSearch appId={APP_ID} apiKey={API_KEY} theme={theme}>
<DocSearchButton translations={{ buttonText: 'Composable API' }} />
<DocSearchAskAiModal
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
/>
<DocSearchAskAiModal askAi={AGENT_ID} indices={[SEARCH_INDEX_NAME]} />
</DocSearch>
);
}

View file

@ -3,6 +3,7 @@ import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function DefaultExperience({
theme,
@ -11,9 +12,9 @@ export default function DefaultExperience({
}): JSX.Element {
return (
<DocSearch
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
indices={[SEARCH_INDEX_NAME]}
appId={APP_ID}
apiKey={API_KEY}
theme={theme}
/>
);

View file

@ -6,6 +6,7 @@ import { useCallback, useRef, useState, type JSX } from 'react';
import { createPortal } from 'react-dom';
import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
let DocSearchModal: typeof DocSearchAskAiModalType | null = null;
@ -94,12 +95,10 @@ function DocSearch({ theme }: { theme: DemoTheme }): JSX.Element {
searchContainer.current &&
createPortal(
<DocSearchModal
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
indices={[SEARCH_INDEX_NAME]}
appId={APP_ID}
apiKey={API_KEY}
askAi={AGENT_ID}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
isAskAiActive={isAskAiActive}

View file

@ -5,6 +5,7 @@ import { Sidepanel, SidepanelButton } from '@docsearch/sidepanel';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID } from '../constants';
export default function BasicHybrid({
theme,
@ -12,24 +13,12 @@ export default function BasicHybrid({
theme: DemoTheme;
}): JSX.Element {
return (
<DocSearch theme={theme}>
<DocSearch appId={APP_ID} apiKey={API_KEY} theme={theme}>
<DocSearchButton />
<DocSearchAskAiModal
indices={['docsearch']}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
/>
<DocSearchAskAiModal askAi={AGENT_ID} indices={['docsearch']} />
<SidepanelButton />
<Sidepanel
indexName="docsearch-markdown"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
/>
<Sidepanel agentId={AGENT_ID} />
</DocSearch>
);
}

View file

@ -3,6 +3,7 @@ import { DocSearch } from '@docsearch/react';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
import { API_KEY, APP_ID, SEARCH_INDEX_NAME } from '../constants';
export default function MultiIndex({
theme,
@ -13,7 +14,7 @@ export default function MultiIndex({
<DocSearch
indices={[
{
name: 'docsearch',
name: SEARCH_INDEX_NAME,
},
{
name: 'tailwindcss',
@ -22,8 +23,8 @@ export default function MultiIndex({
name: 'kubernetes',
},
]}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
appId={APP_ID}
apiKey={API_KEY}
translations={{ button: { buttonText: 'Multi index search' } }}
insights={true}
theme={theme}

View file

@ -4,6 +4,7 @@ import { SidepanelButton, Sidepanel } from '@docsearch/sidepanel';
import type { JSX } from 'react';
import type { DemoTheme } from '../App';
import { AGENT_ID, API_KEY, APP_ID } from '../constants';
export default function SidepanelExample({
theme,
@ -11,13 +12,10 @@ export default function SidepanelExample({
theme: DemoTheme;
}): JSX.Element {
return (
<DocSearch theme={theme}>
<DocSearch appId={APP_ID} apiKey={API_KEY} theme={theme}>
<SidepanelButton variant="inline" />
<Sidepanel
indexName="docsearch"
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
assistantId="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
agentId={AGENT_ID}
variant="floating"
tools={{
printConsoleMessage: {

View file

@ -40,9 +40,7 @@ export default function WTransformItems({
]}
appId="PMZUYBQDAK"
apiKey="24b09689d5b4223813d9b8e48563c8f6"
askAi={{
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
}}
askAi="ccdec697-e3fe-465b-a1c3-657e7bf18aef"
insights={true}
transformItems={(items) => {
return items.map((item: any) => ({

View file

@ -48,6 +48,10 @@ export interface DocSearchRef {
}
export interface DocSearchContext {
/** Default Algolia application ID for child DocSearch/Sidepanel components. */
appId?: string;
/** Default Algolia API key for child DocSearch/Sidepanel components. */
apiKey?: string;
docsearchState: DocSearchState;
setDocsearchState: (newState: DocSearchState) => void;
searchButtonRef: React.RefObject<HTMLButtonElement | null>;
@ -79,6 +83,10 @@ export interface DocSearchCallbacks {
export interface DocSearchProps extends DocSearchCallbacks {
children: Array<JSX.Element | null> | JSX.Element | React.ReactNode | null;
/** Default Algolia application id for descendant DocSearch views. */
appId?: string;
/** Default Algolia API key for descendant DocSearch views. */
apiKey?: string;
theme?: DocSearchTheme;
initialQuery?: string;
keyboardShortcuts?: KeyboardShortcuts;
@ -90,6 +98,8 @@ Context.displayName = 'DocSearchContext';
function DocSearchInner(
{
children,
appId,
apiKey,
theme,
onReady,
onOpen,
@ -267,6 +277,8 @@ function DocSearchInner(
const value: DocSearchContext = React.useMemo(
() => ({
appId,
apiKey,
docsearchState,
setDocsearchState,
searchButtonRef,
@ -282,6 +294,8 @@ function DocSearchInner(
isHybridModeSupported,
}),
[
appId,
apiKey,
docsearchState,
searchButtonRef,
initialQuery,

View file

@ -67,6 +67,18 @@ describe('@docsearch/core', () => {
expect(screen.getByText('State: ready')).toBeInTheDocument();
});
it('provides credential defaults to children', () => {
const { result } = renderCustomHook({
appId: 'app-id',
apiKey: 'api-key',
});
expect(result.current).toMatchObject({
appId: 'app-id',
apiKey: 'api-key',
});
});
it('updates state from children', async () => {
const Comp = (): JSX.Element => {
const { docsearchState, openModal } = useDocSearch();

View file

@ -7,6 +7,8 @@ import { createPortal } from 'react-dom';
export type DocSearchAskAiModalProps = Omit<
ReactDocSearchAskAiModalProps,
| 'appId'
| 'apiKey'
| 'initialScrollY'
| 'isAskAiActive'
| 'isHybridModeSupported'
@ -14,12 +16,15 @@ export type DocSearchAskAiModalProps = Omit<
| 'onAskAiToggle'
| 'onClose'
| 'theme'
>;
> &
Partial<Pick<ReactDocSearchAskAiModalProps, 'appId' | 'apiKey'>>;
export function DocSearchAskAiModal(
props: DocSearchAskAiModalProps
): JSX.Element | null {
const {
appId: providerAppId,
apiKey: providerApiKey,
isModalActive,
onAskAiToggle,
closeModal,
@ -29,6 +34,15 @@ export function DocSearchAskAiModal(
isHybridModeSupported,
} = useDocSearch();
const appId = props.appId ?? providerAppId;
const apiKey = props.apiKey ?? providerApiKey;
if (!appId || !apiKey) {
throw new Error(
'`DocSearchAskAiModal` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.'
);
}
const containerElement = React.useMemo(
() => props.portalContainer ?? document.body,
[props.portalContainer]
@ -43,6 +57,8 @@ export function DocSearchAskAiModal(
const modalProps: ReactDocSearchAskAiModalProps = React.useMemo(
() => ({
...props,
appId,
apiKey,
isAskAiActive,
initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll,
@ -52,6 +68,8 @@ export function DocSearchAskAiModal(
}),
[
props,
appId,
apiKey,
isAskAiActive,
initialQuery,
initialScroll,

View file

@ -7,12 +7,33 @@ import { createPortal } from 'react-dom';
export type DocSearchModalProps = Omit<
ReactDocSearchModalProps,
'initialScrollY' | 'keyboardShortcuts' | 'onClose' | 'theme'
>;
| 'appId'
| 'apiKey'
| 'initialScrollY'
| 'keyboardShortcuts'
| 'onClose'
| 'theme'
> &
Partial<Pick<ReactDocSearchModalProps, 'appId' | 'apiKey'>>;
export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const { isModalActive, closeModal, initialQuery, registerView } =
useDocSearch();
const {
appId: providerAppId,
apiKey: providerApiKey,
isModalActive,
closeModal,
initialQuery,
registerView,
} = useDocSearch();
const appId = props.appId ?? providerAppId;
const apiKey = props.apiKey ?? providerApiKey;
if (!appId || !apiKey) {
throw new Error(
'`DocSearchModal` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.'
);
}
const containerElement = React.useMemo(
() => props.portalContainer ?? document.body,
@ -28,11 +49,13 @@ export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null {
const modalProps: ReactDocSearchModalProps = React.useMemo(
() => ({
...props,
appId,
apiKey,
initialQuery: props.initialQuery ?? initialQuery,
initialScrollY: initialScroll,
onClose: closeModal,
}),
[props, initialQuery, initialScroll, closeModal]
[props, appId, apiKey, initialQuery, initialScroll, closeModal]
);
return isModalActive

View file

@ -47,6 +47,11 @@ const DEFAULT_PROPS: DocSearchModalProps = {
},
};
const PROVIDER_DEFAULT_PROPS: DocSearchModalProps = {
indices: [INDEX_NAME],
transformSearchClient: DEFAULT_PROPS.transformSearchClient,
};
const renderComponent = (
props: DocSearchModalProps = DEFAULT_PROPS,
docsearchProps: Omit<DocSearchProps, 'children'> = {}
@ -67,6 +72,22 @@ describe('@docsearch/modal', () => {
renderComponent();
});
it('uses credentials configured on the provider', () => {
renderComponent(PROVIDER_DEFAULT_PROPS, {
appId: APP_ID,
apiKey: API_KEY,
});
act(() => {
fireEvent.keyDown(document, {
key: 'k',
ctrlKey: true,
});
});
expect(screen.getByText('Search')).toBeInTheDocument();
});
it('opens modal on keyboard shortcut', () => {
renderComponent();

View file

@ -157,11 +157,11 @@ export interface DocSearchProps {
}
function DocSearchComponent(
props: DocSearchProps,
{ appId, apiKey, ...props }: DocSearchProps,
ref: React.ForwardedRef<DocSearchRef>
): JSX.Element {
return (
<DocSearchProvider {...props} ref={ref}>
<DocSearchProvider {...props} appId={appId} apiKey={apiKey} ref={ref}>
<DocSearchInner {...props} />
</DocSearchProvider>
);
@ -169,7 +169,9 @@ function DocSearchComponent(
export const DocSearch = React.forwardRef(DocSearchComponent);
export function DocSearchInner(props: DocSearchProps): JSX.Element {
export function DocSearchInner(
props: Omit<DocSearchProps, 'appId' | 'apiKey'>
): JSX.Element {
const {
searchButtonRef,
keyboardShortcuts,
@ -177,8 +179,14 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element {
initialQuery,
openModal,
closeModal,
appId,
apiKey,
} = useDocSearch();
if (!appId || !apiKey) {
throw new Error('`DocSearch` requires `appId` and `apiKey` props.');
}
return (
<>
<DocSearchButton
@ -191,6 +199,8 @@ export function DocSearchInner(props: DocSearchProps): JSX.Element {
createPortal(
<DocSearchModal
{...props}
appId={appId}
apiKey={apiKey}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}

View file

@ -154,11 +154,6 @@ export interface PromptSuggestions {
}
export interface DocSearchAskAi {
/**
* The index name to use for the Ask AI feature. Your assistant will search
* for relevant documents. If not provided, the root index name will be used.
*/
indexName?: string;
/**
* The API key to use for the ask AI feature. Your assistant will use this API
* key to search the index. If not provided, the API key will be used.
@ -169,8 +164,8 @@ export interface DocSearchAskAi {
* ID to search the index. If not provided, the app ID will be used.
*/
appId?: string;
/** The assistant ID to use for the ask AI feature. */
assistantId: string;
/** The Agent Studio Agent ID to use for the Ask AI feature. */
agentId: string;
/**
* Enables displaying suggested questions on Ask AI's new conversation screen.
*
@ -236,11 +231,16 @@ export interface DocSearchAIProps extends DocSearchProps {
}
function DocSearchAIComponent(
props: DocSearchAIProps,
{ appId, apiKey, ...props }: DocSearchAIProps,
ref: React.ForwardedRef<DocSearchRef>
): JSX.Element {
return (
<DocSearchProvider {...props} ref={ref}>
<DocSearchProvider
{...props}
appId={appId}
apiKey={apiKey}
ref={ref}
>
<DocSearchAIInner {...props} />
</DocSearchProvider>
);
@ -248,7 +248,9 @@ function DocSearchAIComponent(
export const DocSearchAI = React.forwardRef(DocSearchAIComponent);
export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
export function DocSearchAIInner(
props: Omit<DocSearchAIProps, 'appId' | 'apiKey'>
): JSX.Element {
const {
searchButtonRef,
keyboardShortcuts,
@ -259,8 +261,14 @@ export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
openModal,
closeModal,
isHybridModeSupported,
appId,
apiKey,
} = useDocSearch();
if (!appId || !apiKey) {
throw new Error('`DocSearchAI` requires `appId` and `apiKey` props.');
}
return (
<>
<DocSearchButton
@ -273,6 +281,8 @@ export function DocSearchAIInner(props: DocSearchAIProps): JSX.Element {
createPortal(
<DocSearchAskAiModal
{...props}
appId={appId}
apiKey={apiKey}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
translations={props?.translations?.modal}

View file

@ -157,12 +157,12 @@ export function DocSearchAskAiModal({
const askAiConfig = typeof askAi === 'object' ? askAi : null;
const askAiConfigurationId = askAiConfig
? askAiConfig.assistantId
? askAiConfig.agentId
: (askAi as string);
const askAiSearchParameters = askAiConfig?.searchParameters;
const [askAiState, setAskAiState] = React.useState<AskAiState>('initial');
const suggestedQuestions = useSuggestedQuestions({
assistantId: askAiConfigurationId,
agentId: askAiConfigurationId,
searchClient,
suggestedQuestionsEnabled: askAiConfig?.suggestedQuestions,
});
@ -224,10 +224,9 @@ export function DocSearchAskAiModal({
startNewConversation,
restoreConversation,
} = useAskAi({
assistantId: askAiConfigurationId,
agentId: askAiConfigurationId,
apiKey: askAiConfig?.apiKey || apiKey,
appId: askAiConfig?.appId || appId,
indexName: askAiConfig?.indexName || defaultIndexName,
searchParameters: askAiSearchParameters,
tools,
memory: askAiConfig?.memory,

View file

@ -6,6 +6,7 @@ import type { ResultsTranslations } from './Results';
import { Results } from './Results';
import type { ScreenStateProps } from './ScreenState';
import type { InternalDocSearchHit } from './types';
import { removeHighlightTags } from './utils';
export type ResultsScreenTranslations = Partial<{
askAiPlaceholder: string;
@ -27,19 +28,6 @@ export function ResultsScreen({
resultBadgeKey,
...props
}: ResultsScreenProps): JSX.Element {
const { resultsSectionTitle = 'Results' } = translations;
const renderIcon = React.useCallback(
({ item }: { item: InternalDocSearchHit }) => {
return (
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
);
},
[]
);
const renderAction = React.useCallback(() => {
return (
<div className="DocSearch-Hit-action">
@ -70,14 +58,40 @@ export function ResultsScreen({
return null;
}
const title = removeHighlightTags(collection.items[0]);
return (
<Results
{...props}
key={collection.source.sourceId}
translations={translations}
title={resultsSectionTitle}
title={title}
collection={collection}
renderIcon={renderIcon}
renderIcon={({ item, index }) => (
<>
{item.__docsearch_parent && (
<svg className="DocSearch-Hit-Tree" viewBox="0 0 24 54">
<g
stroke="currentColor"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
{item.__docsearch_parent !==
collection.items[index + 1]?.__docsearch_parent ? (
<path d="M8 6v21M20 27H8.3" />
) : (
<path d="M8 6v42M20 27H8.3" />
)}
</g>
</svg>
)}
<div className="DocSearch-Hit-icon">
<SourceIcon type={item.type} />
</div>
</>
)}
renderAction={renderAction}
renderResultBadge={renderResultBadge}
/>

View file

@ -37,17 +37,12 @@ export type SidepanelSearchParameters = {
};
export type DocSearchSidepanelProps = DocSearchCallbacks & {
/** The assistant ID to use for the ask AI feature. */
assistantId: string;
/** Public api key with search permissions for the index. */
/** The Agent Studio agent ID to use for Ask AI. */
agentId: string;
/** Search only API key. */
apiKey: string;
/** Algolia application id used by the search client. */
/** Algolia application ID that hosts the Agent Studio agent. */
appId: string;
/**
* The index name to use for the ask AI feature. Your assistant will search
* this index for relevant documents.
*/
indexName: string;
/**
* Configuration for keyboard shortcuts. Allows enabling/disabling specific
* shortcuts.
@ -103,6 +98,8 @@ function DocSearchSidepanelComponent(
): JSX.Element {
return (
<DocSearch
appId={props.appId}
apiKey={props.apiKey}
keyboardShortcuts={keyboardShortcuts}
theme={theme}
ref={ref}
@ -130,8 +127,16 @@ function DocSearchSidepanelComp({
keyboardShortcuts,
registerView,
initialAskAiMessage,
appId,
apiKey,
} = useDocSearch();
if (!appId || !apiKey) {
throw new Error(
'`Sidepanel` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.'
);
}
const toggleSidepanelState = React.useCallback(() => {
setDocsearchState(docsearchState === 'sidepanel' ? 'ready' : 'sidepanel');
}, [docsearchState, setDocsearchState]);

View file

@ -134,10 +134,9 @@ function SidepanelInner(
isOpen = false,
onOpen,
onClose,
assistantId,
agentId,
apiKey,
appId,
indexName,
variant = 'floating',
searchParameters,
pushSelector,
@ -194,8 +193,7 @@ function SidepanelInner(
restoreConversation,
} = useAskAi({
appId,
indexName,
assistantId,
agentId,
apiKey,
searchParameters,
tools,
@ -204,7 +202,7 @@ function SidepanelInner(
});
const suggestedQuestions = useSuggestedQuestions({
assistantId,
agentId,
suggestedQuestionsEnabled,
searchClient,
});

View file

@ -445,7 +445,7 @@ describe('api', () => {
},
}}
askAi={{
assistantId: '123',
agentId: '123',
promptSuggestions: {
indexName: 'prompt-suggestions',
},

View file

@ -0,0 +1,126 @@
import { cleanup, render } from '@testing-library/react';
import React, { type JSX } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { ResultsScreen } from '../ResultsScreen';
import type { InternalDocSearchHit } from '../types';
function Hit({ children }: { children: React.ReactNode }): JSX.Element {
return <>{children}</>;
}
function createHit(
objectID: string,
lvl0: string,
parent: InternalDocSearchHit | null = null
): InternalDocSearchHit {
const hit: InternalDocSearchHit = {
objectID,
content: null,
url: `/${objectID}`,
url_without_anchor: `/${objectID}`,
type: parent ? 'content' : 'lvl1',
anchor: null,
hierarchy: {
lvl0,
lvl1: 'Installation',
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
_highlightResult: {
content: { value: '', matchLevel: 'none', matchedWords: [] },
hierarchy: {
lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] },
lvl1: { value: 'Installation', matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
},
hierarchy_camel: [],
},
_snippetResult: {
content: { value: '', matchLevel: 'none' },
hierarchy: {
lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] },
lvl1: { value: 'Installation', matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
},
hierarchy_camel: [],
},
__docsearch_parent: parent,
};
return hit;
}
afterEach(() => {
cleanup();
});
describe('ResultsScreen', () => {
it('renders lvl0 headings and tree connectors for grouped child hits', () => {
const parent = createHit('guide-install', 'Guides');
const firstChild = createHit('guide-install-1', 'Guides', parent);
const lastChild = createHit('guide-install-2', 'Guides', parent);
const reference = createHit('reference-install', 'Reference');
const props = {
state: {
activeItemId: null,
collections: [
{
source: { sourceId: 'hits_docs_0' },
items: [parent, firstChild, lastChild],
},
{
source: { sourceId: 'hits_docs_1' },
items: [reference],
},
],
completion: null,
context: {},
isOpen: true,
query: 'install',
status: 'idle',
},
getItemProps: vi.fn(() => ({})),
getListProps: vi.fn(() => ({})),
hitComponent: Hit,
indexName: 'docs',
inputRef: React.createRef<HTMLInputElement>(),
onItemClick: vi.fn(),
refresh: vi.fn(),
setQuery: vi.fn(),
recentSearches: { add: vi.fn(), remove: vi.fn() },
favoriteSearches: { add: vi.fn(), remove: vi.fn() },
conversations: { add: vi.fn(), remove: vi.fn() },
disableUserPersonalization: false,
hasCollections: true,
} as any;
render(<ResultsScreen {...props} />);
expect(document.querySelector('.DocSearch-Hit-source')).toHaveTextContent(
'Guides'
);
expect(
document.querySelectorAll('.DocSearch-Hit-source')[1]
).toHaveTextContent('Reference');
const connectorPaths = Array.from(
document.querySelectorAll('.DocSearch-Hit-Tree path')
).map((path) => path.getAttribute('d'));
expect(connectorPaths).toEqual(['M8 6v42M20 27H8.3', 'M8 6v21M20 27H8.3']);
});
});

View file

@ -157,8 +157,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {
customAction: {
onToolCall,
@ -205,8 +204,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {
customAction: {
onToolCall,
@ -233,10 +231,9 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
tools: {},
agentId: 'assistant-id',
memory: { userToken: 'secure-user-token' },
tools: {},
})
);
@ -250,8 +247,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -266,8 +262,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -284,8 +279,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
searchParameters,
})
@ -308,8 +302,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
indices,
})
@ -335,8 +328,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
searchParameters,
indices,
@ -353,8 +345,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
indices: [],
})
@ -369,8 +360,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -383,8 +373,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -408,8 +397,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -434,8 +422,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -463,8 +450,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -492,8 +478,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -506,8 +491,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -541,8 +525,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
tools: {},
})
);
@ -578,8 +561,7 @@ describe('useAskAi', () => {
useAskAi({
apiKey: 'api-key',
appId: 'app-id',
assistantId: 'assistant-id',
indexName: 'index-name',
agentId: 'assistant-id',
searchParameters,
indices,
tools: {},

View file

@ -3,7 +3,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { AIMessage } from '../types/AskiAi';
import { extractLinksFromMessage } from '../utils/ai';
import { createFacetFilters } from '../utils/createDocSearchSources';
import { getFacetLabel, normalizeFacets } from '../utils/facets';
import {
deriveDefaultSelectedFacetsFromIndex,
getFacetLabel,
normalizeFacets,
} from '../utils/facets';
import {
createObjectStorage,
createStorage,
@ -66,6 +70,34 @@ describe('utils', () => {
);
});
it('derives default selections from index facet filters', () => {
expect(
deriveDefaultSelectedFacetsFromIndex([
{ name: 'docs' },
{
name: 'blog',
searchParameters: {
facetFilters: [
'language:en',
'version:v2',
'invalid',
'empty:',
':value',
],
},
},
{
name: 'api',
searchParameters: { facetFilters: ['language:fr'] },
},
{
name: 'guides',
searchParameters: { facetFilters: 'format:guide' as never },
},
])
).toEqual({ language: 'fr', version: 'v2', format: 'guide' });
});
it('returns configured facetFilters when no dynamic facets are selected', () => {
expect(createFacetFilters(['language:en'], {})).toEqual(['language:en']);
});
@ -79,10 +111,21 @@ describe('utils', () => {
).toEqual(['docusaurus_tag:default', 'language:en', 'version:v2']);
});
it('ignores empty dynamic facet selections', () => {
it('overrides configured filters with dynamic selections for the same facet', () => {
expect(
createFacetFilters(undefined, { language: '', version: 'v2' })
).toEqual(['version:v2']);
createFacetFilters(['language:en', 'version:v2'], {
language: 'fr',
})
).toEqual(['version:v2', 'language:fr']);
});
it('removes configured filters for cleared facet selections', () => {
expect(
createFacetFilters(['language:en', 'version:v2'], {
language: '',
type: 'guide',
})
).toEqual(['version:v2', 'type:guide']);
});
});

View file

@ -3,6 +3,7 @@ import React, { type JSX } from 'react';
import type { DocSearchFacet } from '../DocSearch';
import { ChevronIcon } from '../icons';
import { capitalize } from '../utils';
import type { FacetSelections } from '../utils/createDocSearchSources';
import { getFacetLabel } from '../utils/facets';
import { Chip } from './ui/Chip';
@ -132,7 +133,7 @@ const SelectedFacetChip = React.memo(function SelectedFacetChip({
value,
dismissAriaLabel,
onDismiss,
}: SelectedFacetChipProps): JSX.Element {
}: SelectedFacetChipProps): JSX.Element | null {
const handleDismissFacet = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onDismiss(facetKey, e);
@ -153,7 +154,7 @@ const SelectedFacetChip = React.memo(function SelectedFacetChip({
interface FacetBarProps {
facets: FacetBarFacet[];
selections: Record<string, string>;
selections: FacetSelections;
onSelectionChange: (facet: string, value: string) => void;
clearSelections: () => void;
translations?: FacetBarTranslations;
@ -174,9 +175,15 @@ export const FacetBar = React.memo(function FacetBar({
selectedFacetsAriaLabel = 'Selected search filters',
clearFacetAriaLabel = 'Clear filter:',
} = translations;
const visibleFacetKeys = React.useMemo(
() => new Set(facets.map((f) => f.key)),
[facets]
);
const selectionsToDisplay = React.useMemo(() => {
return Object.entries(selections).filter(([_, value]) => Boolean(value));
}, [selections]);
return Object.entries(selections).filter(
([key, value]) => Boolean(value) && visibleFacetKeys.has(key)
);
}, [selections, visibleFacetKeys]);
const triggerRefs = React.useRef(new Map<string, HTMLButtonElement>());

View file

@ -103,8 +103,8 @@ describe('useDocSearchFacets', () => {
result.current.clearFacetSelections();
});
expect(result.current.facetSelections).toEqual({});
expect(result.current.facetSelectionsRef.current).toEqual({});
expect(result.current.facetSelections).toEqual({ language: '' });
expect(result.current.facetSelectionsRef.current).toEqual({ language: '' });
expect(onSelectionsChange).toHaveBeenCalledTimes(2);
});

View file

@ -5,7 +5,10 @@ import type { DocSearchFacet, DocSearchIndex } from '../DocSearch';
import { useFacetValues } from '../useFacetValues';
import type { useSearchClient } from '../useSearchClient';
import type { FacetSelections } from '../utils/createDocSearchSources';
import { normalizeFacets } from '../utils/facets';
import {
deriveDefaultSelectedFacetsFromIndex,
normalizeFacets,
} from '../utils/facets';
export interface UseDocSearchFacetsProps {
facets?: DocSearchFacet[];
@ -38,18 +41,18 @@ export function useDocSearchFacets({
() => normalizeFacets(facets),
[facets]
);
const normalizedFacetsRef = React.useRef(normalizedFacets);
const facetValues = useFacetValues({
facets: normalizedFacets,
indexes,
searchClient,
});
const [facetSelections, setFacetSelections] = React.useState<FacetSelections>(
{}
() => deriveDefaultSelectedFacetsFromIndex(indexes)
);
const facetSelectionsRef = React.useRef(facetSelections);
const onSelectionsChangeRef = React.useRef(onSelectionsChange);
onSelectionsChangeRef.current = onSelectionsChange;
const visibleFacets = React.useMemo(
() =>
@ -59,6 +62,11 @@ export function useDocSearchFacets({
[facetValues, normalizedFacets]
);
React.useLayoutEffect(() => {
normalizedFacetsRef.current = normalizedFacets;
onSelectionsChangeRef.current = onSelectionsChange;
});
const applySelections = React.useCallback((next: FacetSelections): void => {
facetSelectionsRef.current = next;
setFacetSelections(next);
@ -67,15 +75,13 @@ export function useDocSearchFacets({
const handleFacetSelectionChange = React.useCallback(
(facet: string, value: string): void => {
if (facetSelectionsRef.current[facet] === value) return;
const existing = facetSelectionsRef.current[facet];
if (existing === value) return;
const next = { ...facetSelectionsRef.current };
if (value === '') {
delete next[facet];
} else {
next[facet] = value;
}
next[facet] = value;
applySelections(next);
},
@ -84,7 +90,15 @@ export function useDocSearchFacets({
const clearFacetSelections = React.useCallback(() => {
if (Object.keys(facetSelectionsRef.current).length === 0) return;
applySelections({});
const next = {};
for (const facet of normalizedFacetsRef.current) {
if (facetSelectionsRef.current[facet.key]) {
next[facet.key] = '';
}
}
applySelections(next);
}, [applySelections]);
return {

View file

@ -30,10 +30,9 @@ import type {
type UseChat = UseChatHelpers<AIMessage>;
type UseAskAiParams = {
assistantId: string;
agentId: string;
apiKey: string;
appId: string;
indexName: string;
searchParameters?: AgentStudioSearchParameters;
tools: ToolCalls;
memory?: Memory;
@ -71,7 +70,7 @@ type UseAskAi = (params: UseAskAiParams) => UseAskAiReturn;
type AgentStudioTransportParams = Pick<
UseAskAiParams,
'apiKey' | 'appId' | 'assistantId'
'apiKey' | 'appId' | 'agentId'
> & {
searchParameters?: AgentStudioSearchParameters;
userToken?: string;
@ -81,7 +80,7 @@ type AgentStudioTransportParams = Pick<
const getAgentStudioTransport = ({
appId,
apiKey,
assistantId,
agentId,
searchParameters,
userToken,
indices,
@ -100,7 +99,7 @@ const getAgentStudioTransport = ({
}
return new DefaultChatTransport({
api: `${agentStudioBaseUrl(appId)}/agents/${assistantId}/completions?stream=true&compatibilityMode=ai-sdk-5`,
api: `${agentStudioBaseUrl(appId)}/agents/${agentId}/completions?stream=true&compatibilityMode=ai-sdk-5`,
headers: {
'x-algolia-application-id': appId,
'x-algolia-api-key': apiKey,
@ -126,7 +125,7 @@ const getAgentStudioTransport = ({
};
export const useAskAi: UseAskAi = ({
assistantId,
agentId,
apiKey,
appId,
tools = EMPTY_TOOLS,
@ -141,12 +140,12 @@ export const useAskAi: UseAskAi = ({
getAgentStudioTransport({
apiKey,
appId,
assistantId,
agentId,
searchParameters,
userToken: memory?.userToken,
indices,
}),
[apiKey, appId, assistantId, searchParameters, memory?.userToken, indices]
[apiKey, appId, agentId, searchParameters, memory?.userToken, indices]
);
// Store transport in a ref since it is dependent on unstable dependencies:
@ -224,10 +223,10 @@ export const useAskAi: UseAskAi = ({
const sendFeedback = useCallback<OnAskAiFeedback>(
async (messageId, { thumbs, tags, notes }): Promise<void> => {
if (!assistantId) return;
if (!agentId) return;
const res = await postAgentStudioFeedback({
agentId: assistantId,
agentId,
vote: thumbs,
messageId,
appId,
@ -244,7 +243,7 @@ export const useAskAi: UseAskAi = ({
{ tags, notes }
);
},
[assistantId, appId, apiKey, conversations]
[agentId, appId, apiKey, conversations]
);
const onStopStreaming = useCallback(async (): Promise<void> => {

View file

@ -32,7 +32,6 @@ export function useFacetValues({
const facetKeys = stableFacetKeys ? stableFacetKeys.split(',') : [];
if (facetKeys.length === 0 || indexes.length === 0) {
setFacetValues({});
return () => {
isMounted = false;
};
@ -41,7 +40,6 @@ export function useFacetValues({
searchClient
.search<DocSearchHit>({
requests: indexes.map((index) => ({
...index.searchParameters,
indexName: index.name,
query: '',
hitsPerPage: 0,

View file

@ -10,13 +10,13 @@ import type {
} from '.';
type UseSuggestedQuestionsProps = {
assistantId: string | null;
agentId: string | null;
searchClient: DocSearchTransformClient;
suggestedQuestionsEnabled?: boolean;
};
export const useSuggestedQuestions = ({
assistantId,
agentId,
searchClient,
suggestedQuestionsEnabled = false,
}: UseSuggestedQuestionsProps): SuggestedQuestionHit[] => {
@ -30,7 +30,7 @@ export const useSuggestedQuestions = ({
requests: [
{
indexName: SUGGESTED_QUETIONS_INDEX_NAME,
filters: `state:published AND assistantId:${assistantId}`,
filters: `state:published AND assistantId:${agentId}`,
hitsPerPage: 3,
},
],
@ -41,10 +41,10 @@ export const useSuggestedQuestions = ({
setSuggestedQuestions(result.hits);
};
if (suggestedQuestionsEnabled && assistantId && assistantId !== '') {
if (suggestedQuestionsEnabled && agentId && agentId !== '') {
getSuggestedQuestions();
}
}, [suggestedQuestionsEnabled, assistantId, searchClient]);
}, [suggestedQuestionsEnabled, agentId, searchClient]);
return suggestedQuestions;
};

View file

@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest';
import type { DocSearchHit, InternalDocSearchHit } from '../../types';
import { buildQuerySources } from '../createDocSearchSources';
function createHit({
objectID,
lvl0,
lvl1,
type,
}: {
objectID: string;
lvl0: string;
lvl1: string;
type: DocSearchHit['type'];
}): DocSearchHit {
const hit: DocSearchHit = {
objectID,
content: null,
url: `/${objectID}`,
url_without_anchor: `/${objectID}`,
type,
anchor: null,
hierarchy: {
lvl0,
lvl1,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
_highlightResult: {
content: { value: '', matchLevel: 'none', matchedWords: [] },
hierarchy: {
lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] },
lvl1: { value: lvl1, matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
},
hierarchy_camel: [],
},
_snippetResult: {
content: { value: '', matchLevel: 'none' },
hierarchy: {
lvl0: { value: lvl0, matchLevel: 'none', matchedWords: [] },
lvl1: { value: lvl1, matchLevel: 'none', matchedWords: [] },
lvl2: { value: '', matchLevel: 'none', matchedWords: [] },
lvl3: { value: '', matchLevel: 'none', matchedWords: [] },
lvl4: { value: '', matchLevel: 'none', matchedWords: [] },
lvl5: { value: '', matchLevel: 'none', matchedWords: [] },
lvl6: { value: '', matchLevel: 'none', matchedWords: [] },
},
hierarchy_camel: [],
},
};
return hit;
}
describe('buildQuerySources', () => {
it('creates a source per lvl0 group and scopes parents to that group', async () => {
const sources = await buildQuerySources({
query: 'install',
state: { context: { searchSuggestions: [] } },
setContext: vi.fn(),
setStatus: vi.fn(),
searchClient: {
search: vi.fn().mockResolvedValue({
results: [
{
index: 'docs',
nbHits: 4,
hits: [
createHit({
objectID: 'guide-install',
lvl0: 'Guides',
lvl1: 'Installation',
type: 'lvl1',
}),
createHit({
objectID: 'guide-install-content',
lvl0: 'Guides',
lvl1: 'Installation',
type: 'content',
}),
createHit({
objectID: 'reference-install',
lvl0: 'Reference',
lvl1: 'Installation',
type: 'lvl1',
}),
createHit({
objectID: 'reference-install-content',
lvl0: 'Reference',
lvl1: 'Installation',
type: 'content',
}),
],
},
],
}),
} as any,
indexes: [{ name: 'docs' }],
snippetLength: { current: 15 },
insights: false,
saveRecentSearch: vi.fn(),
onClose: vi.fn(),
facetSelections: { current: {} },
});
expect(sources.map((source) => source.sourceId)).toEqual([
'hits_docs_0',
'hits_docs_1',
]);
const [guideSource, referenceSource] = sources;
const guideItems = (await guideSource.getItems(
{} as never
)) as InternalDocSearchHit[];
const referenceItems = (await referenceSource.getItems(
{} as never
)) as InternalDocSearchHit[];
expect(guideItems.map((item) => item.objectID)).toEqual([
'guide-install',
'guide-install-content',
]);
expect(referenceItems.map((item) => item.objectID)).toEqual([
'reference-install',
'reference-install-content',
]);
expect(guideItems[1].__docsearch_parent?.objectID).toBe('guide-install');
expect(referenceItems[1].__docsearch_parent?.objectID).toBe(
'reference-install'
);
});
});

View file

@ -2,7 +2,11 @@ import type {
AutocompleteSource,
AutocompleteState,
} from '@algolia/autocomplete-core';
import type { SearchParamsObject, SearchResponse } from 'algoliasearch/lite';
import type {
FacetFilters,
SearchParamsObject,
SearchResponse,
} from 'algoliasearch/lite';
import type React from 'react';
import type { DocSearchIndex, DocSearchProps } from '../DocSearch';
@ -34,19 +38,43 @@ export function createFacetFilters(
searchParametersFacetFilters: SearchParamsObject['facetFilters'],
facetSelections: FacetSelections
): SearchParamsObject['facetFilters'] {
const dynamicFacetFilters = Object.entries(facetSelections)
.filter(([, value]) => value)
.map(([facet, value]) => `${facet}:${value}`);
const selections = Object.entries(facetSelections);
const selectedFacets = new Set(selections.map(([facet]) => facet));
const dynamicFacetFilters: string[] = [];
if (dynamicFacetFilters.length === 0) {
for (const [facet, selection] of selections) {
if (selection !== '') {
dynamicFacetFilters.push(`${facet}:${selection}`);
}
}
if (selectedFacets.size === 0) {
return searchParametersFacetFilters;
}
if (!searchParametersFacetFilters) {
return dynamicFacetFilters;
let configuredFacetFilters: FacetFilters = [];
if (
searchParametersFacetFilters &&
Array.isArray(searchParametersFacetFilters)
) {
configuredFacetFilters = searchParametersFacetFilters;
} else if (searchParametersFacetFilters) {
configuredFacetFilters = [searchParametersFacetFilters];
}
return [...searchParametersFacetFilters, ...dynamicFacetFilters];
const remainingFacetFilters = configuredFacetFilters.filter((facetFilter) => {
if (typeof facetFilter !== 'string') {
return true;
}
const separatorIndex = facetFilter.indexOf(':');
const facet = facetFilter.slice(0, separatorIndex);
return separatorIndex <= 0 || !selectedFacets.has(facet);
});
return [...remainingFacetFilters, ...dynamicFacetFilters];
}
export function buildNoQuerySources({
@ -221,10 +249,8 @@ export async function buildQuerySources({
};
}
const items = Object.values(sources).flat();
return {
sourceId: `hits_${result.index}`,
return Object.values<DocSearchHit[]>(sources).map((items, index) => ({
sourceId: `hits_${result.index}_${index}`,
onSelect({ item, event }): void {
saveRecentSearch(item);
if (!isModifierEvent(event)) {
@ -234,36 +260,34 @@ export async function buildQuerySources({
getItemUrl({ item }): string {
return item.url;
},
getItems() {
getItems(): InternalDocSearchHit[] {
return Object.values(
groupBy(items, (item) => item.hierarchy.lvl1, maxResultsPerGroup)
)
.map((groupedHits) =>
groupedHits
.map((item) => {
let parent: InternalDocSearchHit | null = null;
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;
const potentialParent = groupedHits.find(
(siblingItem) =>
siblingItem.type === 'lvl1' &&
siblingItem.hierarchy.lvl1 === item.hierarchy.lvl1
) as InternalDocSearchHit | undefined;
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
if (item.type !== 'lvl1' && potentialParent) {
parent = potentialParent;
}
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
})
.flat()
return {
...item,
__docsearch_parent: parent,
...insightsParams,
};
})
)
.flat();
},
};
}));
});
} catch (error) {
if ((error as Error).name === 'RetryError') {

View file

@ -1,4 +1,6 @@
import type { DocSearchFacet } from '../DocSearch';
import type { DocSearchFacet, DocSearchIndex } from '../DocSearch';
import type { FacetSelections } from './createDocSearchSources';
export const MAX_FACETS = 5;
@ -40,3 +42,36 @@ export function getFacetLabel(facet: DocSearchFacet): string {
.replace(/[._-]+/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
export function deriveDefaultSelectedFacetsFromIndex(
indices: DocSearchIndex[]
): FacetSelections {
const defaultFacets: FacetSelections = {};
for (const index of indices) {
const facetFilters = index.searchParameters?.facetFilters;
for (const facetFilter of Array.isArray(facetFilters)
? facetFilters
: [facetFilters]) {
if (typeof facetFilter !== 'string') {
continue;
}
const separatorIndex = facetFilter.indexOf(':');
if (separatorIndex <= 0) {
continue;
}
const key = facetFilter.slice(0, separatorIndex);
const value = facetFilter.slice(separatorIndex + 1);
if (key && value) {
defaultFacets[key] = value;
}
}
}
return defaultFacets;
}

View file

@ -36,9 +36,8 @@ function App() {
<SidepanelButton />
<Sidepanel
appId="YOUR_APP_ID"
indexName="YOUR_INDEX_NAME"
apiKey="YOUR_SEARCH_API_KEY"
assistantId="YOUR_ASK_AI_ASSISTANT_ID"
agentId="YOUR_ASK_AI_ASSISTANT_ID"
/>
</DocSearch>
);

View file

@ -9,7 +9,11 @@ import type { JSX } from 'react';
import { createPortal } from 'react-dom';
export type SidepanelProps = DocSearchSidepanelProps['panel'] &
Omit<DocSearchSidepanelProps, 'button' | 'panel' | 'theme'> &
Omit<
DocSearchSidepanelProps,
'appId' | 'apiKey' | 'button' | 'panel' | 'theme'
> &
Partial<Pick<DocSearchSidepanelProps, 'appId' | 'apiKey'>> &
SidepanelSearchParameters;
export function Sidepanel({
@ -17,6 +21,8 @@ export function Sidepanel({
...props
}: SidepanelProps): JSX.Element {
const {
appId: providerAppId,
apiKey: providerApiKey,
docsearchState,
setDocsearchState,
keyboardShortcuts,
@ -24,6 +30,15 @@ export function Sidepanel({
initialAskAiMessage,
} = useDocSearch();
const appId = props.appId ?? providerAppId;
const apiKey = props.apiKey ?? providerApiKey;
if (!appId || !apiKey) {
throw new Error(
'`Sidepanel` requires `appId` and `apiKey` props or values configured on the `DocSearch` provider.'
);
}
const handleOpen = React.useCallback((): void => {
setDocsearchState('sidepanel');
}, [setDocsearchState]);
@ -49,12 +64,16 @@ export function Sidepanel({
keyboardShortcuts,
initialMessage: initialAskAiMessage,
...props,
appId,
apiKey,
}),
[
docsearchState,
handleOpen,
handleClose,
props,
appId,
apiKey,
keyboardShortcuts,
initialAskAiMessage,
]

View file

@ -64,7 +64,7 @@ bun add @docsearch/core@^5.0.0-beta @docsearch/modal@^5.0.0-beta @docsearch/css@
## Add keyword search
Wrap the button and keyword modal in one `DocSearch` provider. Pass a public search-only API key. Prefer `indices` over the deprecated `indexName` and `searchParameters` props.
Wrap the button and keyword modal in one `DocSearch` provider. Pass a public search-only API key to the provider; its descendants use these credentials by default.
```tsx title="KeywordSearch.tsx"
import { DocSearch } from '@docsearch/core';
@ -85,14 +85,14 @@ export function KeywordSearch({
indexName,
}: KeywordSearchProps): JSX.Element {
return (
<DocSearch>
<DocSearch appId={appId} apiKey={apiKey}>
<DocSearchButton
translations={{
buttonText: 'Search docs',
buttonAriaLabel: 'Search documentation',
}}
/>
<DocSearchModal appId={appId} apiKey={apiKey} indices={[indexName]} />
<DocSearchModal indices={[indexName]} />
</DocSearch>
);
}
@ -102,7 +102,7 @@ The provider opens the modal when a user selects the button, presses <kbd>Ctrl</
## Add keyword search and Ask AI
Replace `DocSearchModal` with `DocSearchAskAiModal`. Create the assistant in [Agent Studio](/docs/agent-studio/getting-started), then pass its ID through `askAi`.
Replace `DocSearchModal` with `DocSearchAskAiModal`. Create the assistant in [Agent Studio](/docs/agent-studio/getting-started), then configure its ID on the provider.
```tsx title="SearchWithAskAi.tsx"
import { DocSearch } from '@docsearch/core';
@ -115,30 +115,28 @@ interface SearchWithAskAiProps {
appId: string;
apiKey: string;
indexName: string;
assistantId: string;
agentId: string;
}
export function SearchWithAskAi({
appId,
apiKey,
indexName,
assistantId,
agentId,
}: SearchWithAskAiProps): JSX.Element {
return (
<DocSearch>
<DocSearch appId={appId} apiKey={apiKey}>
<DocSearchButton />
<DocSearchAskAiModal
appId={appId}
apiKey={apiKey}
indices={[indexName]}
askAi={{ assistantId }}
askAi={agentId}
/>
</DocSearch>
);
}
```
The `askAi` prop also accepts an assistant ID string. Use the object form when you need options such as `indices`, `searchParameters`, `suggestedQuestions`, `promptSuggestions`, `tools`, or `memory`. See the [React package reference](/docs/packages/react/api-reference) for those option types.
The `askAi` prop accepts an assistant ID string or an object with `agentId`. Use the object form when you need options such as `indices`, `searchParameters`, `suggestedQuestions`, `promptSuggestions`, `tools`, or `memory`. Set `appId` and `apiKey` on an individual modal or Sidepanel only when they must override the provider's defaults.
## Understand the shared state
@ -200,7 +198,7 @@ interface ControlledSearchProps {
appId: string;
apiKey: string;
indexName: string;
assistantId: string;
agentId: string;
onReady?: () => void;
onOpen?: () => void;
onClose?: () => void;
@ -231,7 +229,7 @@ export function ControlledSearch(props: ControlledSearchProps): JSX.Element {
appId={props.appId}
apiKey={props.apiKey}
indices={[props.indexName]}
askAi={{ assistantId: props.assistantId }}
askAi={props.agentId}
/>
</DocSearch>
);
@ -339,7 +337,7 @@ interface LazySearchProps {
appId: string;
apiKey: string;
indexName: string;
assistantId: string;
agentId: string;
}
export function LazySearch(props: LazySearchProps): JSX.Element {
@ -354,7 +352,7 @@ export function LazySearch(props: LazySearchProps): JSX.Element {
appId={props.appId}
apiKey={props.apiKey}
indices={[props.indexName]}
askAi={{ assistantId: props.assistantId }}
askAi={props.agentId}
/>
</DocSearch>
);
@ -401,12 +399,12 @@ Add `@docsearch/css/dist/_askai.css` when you use `DocSearchAskAiModal`. Bundler
| API | Required configuration | Provider-managed behavior |
| --- | --- | --- |
| `DocSearch` | `children` | State, theme, initial query, shortcuts, focus restoration, lifecycle callbacks, and `DocSearchRef` |
| `DocSearch` | `children` | State, credentials defaults, theme, initial query, shortcuts, focus restoration, lifecycle callbacks, and `DocSearchRef` |
| `DocSearchButton` | None | Button ref, theme, shortcuts, and opening keyword search |
| `DocSearchModal` | `appId`, `apiKey`, and at least one `indices` entry or deprecated `indexName` | Open state, close action, initial scroll position, initial query, theme, and shortcuts |
| `DocSearchModal` | At least one `indices` entry or deprecated `indexName`; `appId` and `apiKey` must be set here or on `DocSearch` | Open state, close action, initial scroll position, initial query, theme, and shortcuts |
| `DocSearchAskAiModal` | The keyword modal configuration plus `askAi` | Keyword modal behavior, Ask AI state, Ask AI transitions, and hybrid detection |
| `useDocSearch` | A parent `DocSearch` provider | Reads the context and throws when used outside the provider |
`DocSearchButton` accepts native React button props and `translations`. The connected wrapper doesn't accept `theme` or `keyboardShortcuts`; set those on `DocSearch`.
Both connected modal wrappers accept the corresponding low-level modal options, except the state and lifecycle fields supplied by the provider. Consult the [modal API](/docs/packages/modal/api) before adding options.
Both connected modal wrappers accept the corresponding low-level modal options, except the state and lifecycle fields supplied by the provider. Explicit `appId` and `apiKey` props override their respective provider values. Consult the [modal API](/docs/packages/modal/api) before adding options.

View file

@ -202,7 +202,7 @@ V5 adds `askAi.indices`, `askAi.tools`, `askAi.memory`, and `askAi.promptSuggest
## 4. Move keyword configuration to `indices`
`indexName` and root `searchParameters` still work in v5, but both are deprecated.
`indexName` and root `searchParameters` both have been removed in `v5`.
```diff title="app.js"
docsearch({
@ -223,7 +223,7 @@ V5 adds `askAi.indices`, `askAi.tools`, `askAi.memory`, and `askAi.promptSuggest
});
```
Use one item per index. DocSearch queries them in array order. If you temporarily pass both `indexName` and `indices`, it queries `indexName` first, so remove the old option to avoid duplicate requests.
Use one item per index. DocSearch queries them in array order.
## 5. Update result customization

View file

@ -43,7 +43,7 @@ Public API key with search permission.
> `type: Array<string | DocSearchIndex>` | **optional**
Indices to search in display order. Provide `indices` or the deprecated [`indexName`](#indexname).
Indices to search in display order.
```tsx title="Search.tsx"
<DocSearch
@ -61,12 +61,6 @@ Indices to search in display order. Provide `indices` or the deprecated [`indexN
/>
```
### `indexName`
> `type: string` | **optional** | **deprecated**
Single index to search. Use [`indices`](#indices) instead. If you pass both options, DocSearch queries `indexName` first.
### `facets`
> `type: DocSearchFacet[]` | **optional**
@ -87,12 +81,6 @@ Theme written to `document.documentElement.dataset.theme`. By default, DocSearch
Search input placeholder. The active experience supplies the default.
### `searchParameters`
> `type: SearchParamsObject` | **optional** | **deprecated**
Search parameters for `indexName`. Set `searchParameters` on an [`indices`](#indices) item instead.
### `maxResultsPerGroup`
> `type: number` | **optional**
@ -250,11 +238,11 @@ Primitive values render as text and arrays of primitives render as a comma-separ
Agent Studio assistant ID or configuration. This prop is required by `DocSearchAI`.
#### `assistantId`
#### `agentId`
> `type: string` | **required**
Agent Studio assistant ID.
Agent Studio agent ID.
#### `appId`
@ -268,12 +256,6 @@ Application ID for Agent Studio. Defaults to the root `appId`.
API key for Agent Studio. Defaults to the root `apiKey`.
#### `indexName`
> `type: string` | **optional**
Index for Agent Studio. Defaults to the first normalized keyword index.
#### `suggestedQuestions`
> `type: boolean` | **optional**
@ -289,7 +271,7 @@ Search parameters keyed by index name. Each value supports `filters`, `attribute
```tsx title="Search.tsx"
<DocSearchAI
askAi={{
assistantId: 'YOUR_ASSISTANT_ID',
agentId: 'YOUR_AGENT_ID',
searchParameters: {
docs: {
filters: 'language:en',
@ -392,7 +374,6 @@ Opens a registered Sidepanel. Does nothing when no Sidepanel is registered.
- `react`, `react-dom`, and `@types/react` support versions `>=16.8.0 <20.0.0`.
- `search-insights` supports versions `>=1 <3` and is optional.
- The browser build targets ES2017.
- `indexName` and root `searchParameters` remain supported but are deprecated. Move to `indices`.
- `UseDocSearchKeyboardEventsProps.onInput` and `searchButtonRef` remain accepted for compatibility but are deprecated.
- `DocSearch` is keyword-only in v5. Use `DocSearchAI` for Ask AI.

View file

@ -82,7 +82,7 @@ export default {
apiKey: '24b09689d5b4223813d9b8e48563c8f6',
indices: [{ name: 'docsearch' }],
askAi: {
assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
agentId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef',
},
sidePanel: true,
contextualSearch: true,

View file

@ -80,17 +80,26 @@ export default function DemoApp() {
HTMLElement.prototype.focus = function focusNoScroll(options) {
if (!userEngaged) return undefined;
return nativeFocus.call(this, { preventScroll: true, ...(options ?? {}) });
return nativeFocus.call(this, {
preventScroll: true,
...(options ?? {}),
});
};
Element.prototype.scrollIntoView = function containedScrollIntoView(arg) {
const behavior = typeof arg === 'object' && arg?.behavior ? arg.behavior : 'auto';
const behavior =
typeof arg === 'object' && arg?.behavior ? arg.behavior : 'auto';
let ancestor = this.parentElement;
while (ancestor) {
const overflowY = getComputedStyle(ancestor).overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && ancestor.scrollHeight > ancestor.clientHeight) {
if (
(overflowY === 'auto' || overflowY === 'scroll') &&
ancestor.scrollHeight > ancestor.clientHeight
) {
const top =
this.getBoundingClientRect().top - ancestor.getBoundingClientRect().top + ancestor.scrollTop;
this.getBoundingClientRect().top -
ancestor.getBoundingClientRect().top +
ancestor.scrollTop;
ancestor.scrollTo({ top, behavior });
return;
}
@ -164,10 +173,9 @@ export default function DemoApp() {
<DocSearchSidepanel
ref={sidepanelRef}
theme={theme}
indexName={INDEX_NAME}
appId={APP_ID}
apiKey={API_KEY}
assistantId={ASSISTANT_ID}
agentId={ASSISTANT_ID}
button={{ variant: 'inline' }}
panel={{ suggestedQuestions: true }}
/>
@ -186,7 +194,7 @@ export default function DemoApp() {
indices={[INDEX_NAME]}
appId={APP_ID}
apiKey={API_KEY}
askAi={{ assistantId: ASSISTANT_ID }}
askAi={ASSISTANT_ID}
navigator={navigator}
/>
</DocSearch>

View file

@ -31,7 +31,7 @@ npm install @docsearch/react@4 @docsearch/css@4
import { DocSearch } from '@docsearch/react';
import '@docsearch/css';
<DocSearch appId="YOUR_APP_ID" indexName="YOUR_INDEX" apiKey="YOUR_SEARCH_KEY" />
<DocSearch appId="YOUR_APP_ID" indices=["YOUR_INDEX"] apiKey="YOUR_SEARCH_KEY" />
\`\`\`
### Docusaurus (recommended)
@ -47,7 +47,7 @@ themeConfig: {
docsearch: {
appId: 'YOUR_APP_ID',
apiKey: 'YOUR_SEARCH_KEY',
indexName: 'YOUR_INDEX',
indices: ['YOUR_INDEX'],
},
},
\`\`\`