diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 46164996f7..c9f26dd5c6 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -2,6 +2,7 @@ import * as zustand from 'zustand'; +import { useCurrentContent } from '@/components/hooks'; import { useLanguage } from '@/intl/client'; import { tString } from '@/intl/translate'; import { @@ -15,6 +16,7 @@ import assertNever from 'assert-never'; import * as React from 'react'; import { getInsightsSession, useTrackEvent } from '../Insights'; import { useSetSearchState } from '../Search'; +import { addRecentSearchQuery } from '../Search/recent-queries'; import type { AnyAIControl } from './controls'; import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl'; import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions'; @@ -170,6 +172,7 @@ export function AIChatProvider(props: { const messageContextRef = useAIMessageContextRef(); const trackEvent = useTrackEvent(); const setSearchState = useSetSearchState(); + const { siteSpaceId } = useCurrentContent(); const language = useLanguage(); // Event listeners storage @@ -465,6 +468,10 @@ export function AIChatProvider(props: { // For first message, update the ask parameter in URL if (messages.length === 0) { + if (siteSpaceId) { + addRecentSearchQuery(siteSpaceId, input.message, 'ask'); + } + setSearchState((prev) => ({ ask: input.message, query: prev?.query ?? null, @@ -508,7 +515,7 @@ export function AIChatProvider(props: { streamResponse({ message: input.message }); }, - [setSearchState, trackEvent, streamResponse, language] + [setSearchState, siteSpaceId, trackEvent, streamResponse, language] ); // Clear the conversation and reset ask parameter diff --git a/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx b/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx index ada3f86ce3..0ad59d763d 100644 --- a/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx @@ -1,5 +1,7 @@ +import { useCurrentContent } from '@/components/hooks'; import { tString, useLanguage } from '@/intl/client'; import type { AIChatController } from '../AI'; +import { useRecentSearchQueries } from '../Search/recent-queries'; import { Button } from '../primitives'; export default function AIChatSuggestedQuestions(props: { @@ -7,16 +9,31 @@ export default function AIChatSuggestedQuestions(props: { suggestions?: string[]; }) { const language = useLanguage(); - const { chatController, suggestions: _suggestions } = props; + const { siteSpaceId } = useCurrentContent(); + const recentQueries = useRecentSearchQueries(siteSpaceId ?? ''); + const { chatController, suggestions: configuredSuggestions } = props; - const suggestions = - _suggestions && _suggestions.length > 0 - ? _suggestions - : [ - tString(language, 'ai_chat_suggested_questions_about_this_page'), - tString(language, 'ai_chat_suggested_questions_read_next'), - tString(language, 'ai_chat_suggested_questions_example'), - ]; + const defaultSuggestions = [ + tString(language, 'ai_chat_suggested_questions_about_this_page'), + tString(language, 'ai_chat_suggested_questions_read_next'), + tString(language, 'ai_chat_suggested_questions_example'), + ]; + const baseSuggestions = + configuredSuggestions && configuredSuggestions.length > 0 + ? configuredSuggestions + : defaultSuggestions; + + const suggestions = [ + ...recentQueries.filter((entry) => entry.action === 'ask').map((entry) => entry.query), + ...baseSuggestions, + ].reduce((acc, suggestion) => { + if (acc.includes(suggestion)) { + return acc; + } + + acc.push(suggestion); + return acc; + }, []); return (
{ + if (assistant.mode === 'search' && siteSpaceId) { + addRecentSearchQuery(siteSpaceId, query, 'ask'); + } onSelect?.(); assistant.open(query); } diff --git a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx index 21f2717773..60fc1cf32c 100644 --- a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx @@ -1,43 +1,57 @@ import React from 'react'; import type { Assistant } from '@/components/AI'; +import { useCurrentContent } from '@/components/hooks'; import { tString, useLanguage } from '@/intl/client'; import { SearchResultItem } from './SearchResultItem'; +import { addRecentSearchQuery } from './recent-queries'; import { useSearchLink } from './useSearch'; export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem( props: { question: string; + action: 'ask' | 'search'; active: boolean; - recommended?: boolean; assistant: Assistant; - style?: React.CSSProperties; }, ref: React.Ref ) { - const { question, recommended = false, active, assistant, style, ...rest } = props; + const { question, action, active, assistant, ...rest } = props; const language = useLanguage(); + const { siteSpaceId } = useCurrentContent(); const getLinkProp = useSearchLink(); + const shouldAsk = action === 'ask'; return ( { - assistant.open(question); - } + shouldAsk + ? { + ask: question, + query: null, + open: assistant.mode === 'search', + } + : { + ask: null, + query: question, + open: true, + }, + shouldAsk + ? () => { + if (assistant.mode === 'search' && siteSpaceId) { + addRecentSearchQuery(siteSpaceId, question, 'ask'); + } + assistant.open(question); + } + : undefined )} active={active} - leadingIcon="search" + leadingIcon={shouldAsk ? assistant.icon : 'search'} {...rest} > {question} diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 9a0d6886e8..29848568ff 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import React from 'react'; import { useAI } from '@/components/AI'; +import { useCurrentContent } from '@/components/hooks'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; @@ -13,18 +14,13 @@ import { SearchPageResultItem } from './SearchPageResultItem'; import { SearchQuestionResultItem } from './SearchQuestionResultItem'; import { SearchRecordResultItem } from './SearchRecordResultItem'; import { SearchResultItem } from './SearchResultItem'; -import type { OrderedComputedResult } from './search-types'; -import type { LocalPageResult } from './useLocalSearchResults'; +import { addRecentSearchQuery } from './recent-queries'; +import type { ResultType } from './useSearchResults'; export interface SearchResultsRef { select(): boolean; } -type ResultType = - | OrderedComputedResult - | LocalPageResult - | { type: 'recommended-question'; id: string; question: string }; - function getResultKey(item: ResultType): string { switch (item.type) { case 'local-page': @@ -61,6 +57,7 @@ export const SearchResults = React.forwardRef(function SearchResults( const { children, id, query, results, fetching, cursor, error, onResultSelect } = props; const language = useLanguage(); + const { siteSpaceId } = useCurrentContent(); const shouldAnimateResults = !query || fetching; const previousCursor = React.useRef(cursor); const seenResultKeys = React.useRef(new Set()); @@ -183,11 +180,24 @@ export const SearchResults = React.forwardRef(function SearchResults( const itemKey = getResultKey(item); const shouldAnimateItem = shouldAnimateResults || !seenResultKeys.current.has(itemKey); + const handleResultSelect = () => { + if ( + query && + siteSpaceId && + (item.type === 'local-page' || + item.type === 'page' || + item.type === 'record') + ) { + addRecentSearchQuery(siteSpaceId, query, 'search'); + } + + onResultSelect?.(); + }; const resultItemProps = { 'aria-posinset': index + 1, 'aria-setsize': results.length, id: `${id}-${index}`, - onClickCapture: () => onResultSelect?.(), + onClickCapture: handleResultSelect, }; switch (item.type) { case 'local-page': @@ -253,12 +263,9 @@ export const SearchResults = React.forwardRef(function SearchResults( refs.current[index] = ref; }} question={item.question} + action={item.action} active={index === cursor} assistant={primaryAssistant} - recommended - style={{ - animationDelay: `${index * 25}ms,${100 + index * 25}ms`, - }} {...resultItemProps} /> diff --git a/packages/gitbook/src/components/Search/empty-search-results.ts b/packages/gitbook/src/components/Search/empty-search-results.ts new file mode 100644 index 0000000000..deb2614488 --- /dev/null +++ b/packages/gitbook/src/components/Search/empty-search-results.ts @@ -0,0 +1,62 @@ +import type { RecentSearchQueryEntry } from './recent-queries'; + +export type RecommendedQuestionResult = { + type: 'recommended-question'; + id: string; + question: string; + action: 'ask' | 'search'; +}; + +export function createRecommendedQuestionResult( + id: string, + question: string, + action: 'ask' | 'search' = 'ask' +): RecommendedQuestionResult { + return { + type: 'recommended-question', + id, + question, + action, + }; +} + +export function getEmptySearchResults(props: { + withAI: boolean; + recentQueries: RecentSearchQueryEntry[]; + recommendedQuestions: RecommendedQuestionResult[]; +}): RecommendedQuestionResult[] { + const { withAI, recentQueries, recommendedQuestions } = props; + + if (!withAI) { + return []; + } + + const seenQuestions = new Set(); + const results: RecommendedQuestionResult[] = []; + + for (const recentQuery of recentQueries) { + if (seenQuestions.has(recentQuery.query)) { + continue; + } + + seenQuestions.add(recentQuery.query); + results.push( + createRecommendedQuestionResult( + `recent-query-${recentQuery.action}-${recentQuery.query}`, + recentQuery.query, + recentQuery.action + ) + ); + } + + for (const question of recommendedQuestions) { + if (seenQuestions.has(question.question)) { + continue; + } + + seenQuestions.add(question.question); + results.push(question); + } + + return results; +} diff --git a/packages/gitbook/src/components/Search/recent-queries.ts b/packages/gitbook/src/components/Search/recent-queries.ts new file mode 100644 index 0000000000..2bdc237852 --- /dev/null +++ b/packages/gitbook/src/components/Search/recent-queries.ts @@ -0,0 +1,168 @@ +'use client'; + +import React from 'react'; + +import { getLocalStorageItem, setLocalStorageItem } from '@/lib/browser'; + +const STORAGE_KEY = '@gitbook/searchRecentQueries'; +const MAX_RECENT_QUERIES = 5; +const EMPTY_RECENT_QUERIES: RecentSearchQueryEntry[] = []; + +export type RecentSearchQueryAction = 'ask' | 'search'; + +export type RecentSearchQueryEntry = { + query: string; + action: RecentSearchQueryAction; +}; + +type RecentQueriesState = Record; + +const listeners = new Set<() => void>(); +const EMPTY_RECENT_QUERIES_STATE: RecentQueriesState = {}; + +let globalRecentQueriesState = EMPTY_RECENT_QUERIES_STATE; +let hasStorageListener = false; +let hasLoadedRecentQueriesState = false; + +function parseRecentSearchQueryEntry(value: unknown): RecentSearchQueryEntry | null { + if ( + !value || + typeof value !== 'object' || + !('query' in value) || + typeof value.query !== 'string' || + !('action' in value) || + (value.action !== 'ask' && value.action !== 'search') + ) { + return null; + } + + const query = value.query.trim(); + if (!query) { + return null; + } + + return { + query, + action: value.action, + }; +} + +function sanitizeQueries(value: unknown): RecentSearchQueryEntry[] { + if (!Array.isArray(value)) { + return EMPTY_RECENT_QUERIES; + } + + const seen = new Set(); + + return value.reduce((acc, entry) => { + const normalizedEntry = parseRecentSearchQueryEntry(entry); + if (!normalizedEntry || seen.has(normalizedEntry.query)) { + return acc; + } + + seen.add(normalizedEntry.query); + if (acc.length < MAX_RECENT_QUERIES) { + acc.push(normalizedEntry); + } + return acc; + }, []); +} + +function sanitizeState(value: unknown): RecentQueriesState { + if (!value || typeof value !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(value).flatMap(([siteSpaceId, queries]) => { + if (typeof siteSpaceId !== 'string') { + return []; + } + + const sanitized = sanitizeQueries(queries); + return sanitized.length > 0 ? [[siteSpaceId, sanitized]] : []; + }) + ); +} + +function readRecentQueriesState(): RecentQueriesState { + return sanitizeState(getLocalStorageItem(STORAGE_KEY, {})); +} + +function ensureRecentQueriesStateLoaded() { + if (typeof window === 'undefined' || hasLoadedRecentQueriesState) { + return; + } + + globalRecentQueriesState = readRecentQueriesState(); + hasLoadedRecentQueriesState = true; +} + +function emitChange() { + listeners.forEach((listener) => listener()); +} + +function ensureStorageListener() { + if (typeof window === 'undefined' || hasStorageListener) { + return; + } + + ensureRecentQueriesStateLoaded(); + + window.addEventListener('storage', () => { + globalRecentQueriesState = readRecentQueriesState(); + hasLoadedRecentQueriesState = true; + emitChange(); + }); + hasStorageListener = true; +} + +function writeRecentQueriesState(nextState: RecentQueriesState) { + globalRecentQueriesState = nextState; + setLocalStorageItem(STORAGE_KEY, nextState); + emitChange(); +} + +export function addRecentSearchQuery( + siteSpaceId: string, + query: string, + action: RecentSearchQueryAction +) { + const normalizedQuery = query.trim(); + if (!siteSpaceId || !normalizedQuery) { + return; + } + + ensureRecentQueriesStateLoaded(); + + const nextQueries: RecentSearchQueryEntry[] = [ + { + query: normalizedQuery, + action, + }, + ...(globalRecentQueriesState[siteSpaceId] ?? []).filter( + (existingQuery) => existingQuery.query !== normalizedQuery + ), + ].slice(0, MAX_RECENT_QUERIES); + + writeRecentQueriesState({ + ...globalRecentQueriesState, + [siteSpaceId]: nextQueries, + }); +} + +export function useRecentSearchQueries(siteSpaceId: string): RecentSearchQueryEntry[] { + const subscribe = React.useCallback((listener: () => void) => { + ensureStorageListener(); + listeners.add(listener); + listener(); + return () => listeners.delete(listener); + }, []); + + const getSnapshot = React.useCallback( + () => globalRecentQueriesState[siteSpaceId] ?? EMPTY_RECENT_QUERIES, + [siteSpaceId] + ); + + return React.useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_RECENT_QUERIES); +} diff --git a/packages/gitbook/src/components/Search/useSearchController.tsx b/packages/gitbook/src/components/Search/useSearchController.tsx index 3768c42023..6a41a08e37 100644 --- a/packages/gitbook/src/components/Search/useSearchController.tsx +++ b/packages/gitbook/src/components/Search/useSearchController.tsx @@ -7,6 +7,7 @@ import { useAI } from '../AI'; import { useTrackEvent } from '../Insights'; import { useBodyLoaded } from '../primitives'; import type { SearchResultsRef } from './SearchResults'; +import { addRecentSearchQuery } from './recent-queries'; import type { SearchBaseProps } from './search-props'; import { useSearchState, useSetSearchState } from './useSearch'; import { useSearchResults } from './useSearchResults'; @@ -214,6 +215,10 @@ export function useSearchController(props: SearchBaseProps) { return; } + if (assistant.mode === 'search') { + addRecentSearchQuery(siteSpace.id, normalizedQuery, 'ask'); + } + abort(); assistant.open(normalizedQuery); setSearchState({ @@ -223,7 +228,7 @@ export function useSearchController(props: SearchBaseProps) { open: assistant.mode === 'search', }); }, - [abort, assistants, normalizedQuery, setSearchState, state?.scope] + [abort, assistants, normalizedQuery, setSearchState, siteSpace.id, state?.scope] ); const askCount = normalizedQuery && !showAsk ? assistants.length : 0; diff --git a/packages/gitbook/src/components/Search/useSearchResults.ts b/packages/gitbook/src/components/Search/useSearchResults.ts index 810f807e2f..1d433d58aa 100644 --- a/packages/gitbook/src/components/Search/useSearchResults.ts +++ b/packages/gitbook/src/components/Search/useSearchResults.ts @@ -3,12 +3,18 @@ import React from 'react'; import { assert } from 'ts-essentials'; +import { + type RecommendedQuestionResult, + createRecommendedQuestionResult, + getEmptySearchResults, +} from './empty-search-results'; import type { OrderedComputedResult } from './search-types'; import { streamRecommendedQuestions } from './server-actions'; import { useAI } from '@/components/AI'; import assertNever from 'assert-never'; import { useTrackEvent } from '../Insights'; +import { useRecentSearchQueries } from './recent-queries'; import { type MergedPageResult, reciprocalRankFusion } from './reciprocalRankFusion'; import { type LocalPageResult, useLocalSearchResults } from './useLocalSearchResults'; import type { SearchScope } from './useSearch'; @@ -17,7 +23,7 @@ export type ResultType = | OrderedComputedResult | LocalPageResult | MergedPageResult - | { type: 'recommended-question'; id: string; question: string }; + | RecommendedQuestionResult; export type { LocalPageResult, MergedPageResult }; @@ -27,7 +33,7 @@ export type { LocalPageResult, MergedPageResult }; * have different recommended questions for different spaces of the same site. * It should not be used outside of an useEffect. */ -const cachedRecommendedQuestions: Map = new Map(); +const cachedRecommendedQuestions: Map = new Map(); export function useSearchResults(props: { asEmbeddable?: boolean; @@ -90,6 +96,7 @@ export function useSearchResults(props: { const { assistants } = useAI(); const withAI = assistants.length > 0; + const recentQueries = useRecentSearchQueries(siteSpaceId); React.useEffect(() => { if (disabled) { @@ -119,7 +126,7 @@ export function useSearchResults(props: { // We currently have a bug where the same question can be returned multiple times. // This is a workaround to avoid that. const questions = new Set(); - const recommendedQuestions: ResultType[] = []; + const recommendedQuestions: RecommendedQuestionResult[] = []; if (suggestions && suggestions.length > 0) { suggestions.forEach((question) => { @@ -146,11 +153,7 @@ export function useSearchResults(props: { } questions.add(question); - recommendedQuestions.push({ - type: 'recommended-question', - id: question, - question, - }); + recommendedQuestions.push(createRecommendedQuestionResult(question, question)); cachedRecommendedQuestions.set(siteSpaceId, recommendedQuestions); if (!cancelled) { @@ -266,24 +269,24 @@ export function useSearchResults(props: { // Re-runs immediately whenever either result set changes. const results = React.useMemo(() => { if (!query) { - // No query: show recommended questions (AI-only path) or nothing. - if (withAI && cachedRecommendedQuestions.has(siteSpaceId)) { - return cachedRecommendedQuestions.get(siteSpaceId) ?? []; - } - if (suggestions && suggestions.length > 0) { - return suggestions.map((question, index) => ({ - type: 'recommended-question' as const, - id: `recommended-question-${index}`, - question, - })); - } - return []; + const recommendedQuestions = + cachedRecommendedQuestions.get(siteSpaceId) ?? + suggestions?.map((question, index) => + createRecommendedQuestionResult(`recommended-question-${index}`, question) + ) ?? + []; + + return getEmptySearchResults({ + withAI, + recentQueries, + recommendedQuestions, + }); } const merged = reciprocalRankFusion(localResults, remoteState.results, query); return merged; - }, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions]); + }, [localResults, remoteState.results, query, withAI, siteSpaceId, suggestions, recentQueries]); return { results,