diff --git a/.changeset/odd-candles-move.md b/.changeset/odd-candles-move.md new file mode 100644 index 0000000000..0fa9828619 --- /dev/null +++ b/.changeset/odd-candles-move.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Implement basic URL scheme for assistant with ask diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 0db15e0517..8bf90bd018 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1,4 +1,5 @@ import { + CustomizationAIMode, CustomizationBackground, CustomizationCorners, CustomizationDefaultMonospaceFont, @@ -19,6 +20,7 @@ import { import { getSiteAPIToken } from '../tests/utils'; import { + type Test, type TestsCase, allDeprecatedThemePresets, allLocales, @@ -34,6 +36,250 @@ import { waitForNotFound, } from './util'; +const searchTestCases: Test[] = [ + { + name: 'Search - AI Mode: None - Complete flow', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.None, + }, + }), + screenshot: false, + run: async (page) => { + const searchInput = page.getByTestId('search-input'); + await searchInput.focus(); + await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions. + + // Fill search input, expecting search results + await searchInput.fill('gitbook'); + await expect(page.getByTestId('search-results')).toBeVisible(); + const pageResults = await page.getByTestId('search-page-result').all(); + await expect(pageResults.length).toBeGreaterThan(2); + const pageSectionResults = await page.getByTestId('search-page-section-result').all(); + await expect(pageSectionResults.length).toBeGreaterThan(2); + await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None. + }, + }, + { + name: 'Search - AI Mode: None - Keyboard shortcut', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.None, + }, + }), + screenshot: false, + run: async (page) => { + await page.keyboard.press('ControlOrMeta+K'); + await expect(page.getByTestId('search-input')).toBeFocused(); + }, + }, + { + name: 'Search - AI Mode: None - URL query (Initial)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.None, + }, + })}&q=`, + run: async (page) => { + await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions. + }, + }, + { + name: 'Search - AI Mode: None - URL query (Results)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.None, + }, + })}&q=gitbook`, + run: async (page) => { + await expect(page.getByTestId('search-input')).toBeFocused(); + await expect(page.getByTestId('search-input')).toHaveValue('gitbook'); + await expect(page.getByTestId('search-results')).toBeVisible(); + }, + }, + { + name: 'Search - AI Mode: Search - Complete flow', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Search, + }, + }), + screenshot: false, + run: async (page) => { + const searchInput = page.getByTestId('search-input'); + + // Focus search input, expecting recommended questions + await searchInput.focus(); + await expect(page.getByTestId('search-results')).toBeVisible(); + const recommendedQuestions = await page + .getByTestId('search-recommended-question') + .all(); + await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions + + // Fill search input, expecting AI search option + await searchInput.fill('What is gitbook?'); + await expect(page.getByTestId('search-results')).toBeVisible(); + const aiSearchResult = page.getByTestId('search-ask-question'); + await expect(aiSearchResult).toBeVisible(); + await aiSearchResult.click(); + await expect(page.getByTestId('search-ask-answer')).toBeVisible({ + timeout: 15_000, + }); + }, + }, + { + name: 'Search - AI Mode: Search - URL query (Initial)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Search, + }, + })}&q=`, + screenshot: false, + run: async (page) => { + await expect(page.getByTestId('search-input')).toBeFocused(); + await expect(page.getByTestId('search-results')).toBeVisible(); + const recommendedQuestions = await page + .getByTestId('search-recommended-question') + .all(); + await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions + }, + }, + { + name: 'Search - AI Mode: Search - URL query (Results)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Search, + }, + })}&q=gitbook`, + screenshot: false, + run: async (page) => { + await expect(page.getByTestId('search-input')).toBeFocused(); + await expect(page.getByTestId('search-input')).toHaveValue('gitbook'); + await expect(page.getByTestId('search-results')).toBeVisible(); + }, + }, + { + name: 'Ask - AI Mode: Search - URL query (Ask initial)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Search, + }, + })}&ask=`, + screenshot: false, + run: async (page) => { + await expect(page.getByTestId('search-input')).toBeFocused(); + await expect(page.getByTestId('search-results')).toBeVisible(); + const recommendedQuestions = await page + .getByTestId('search-recommended-question') + .all(); + await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions + }, + }, + { + name: 'Ask - AI Mode: Search - URL query (Ask results)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Search, + }, + })}&ask=What+is+GitBook%3F`, + screenshot: false, + run: async (page) => { + await expect(page.getByTestId('search-input')).toBeFocused(); + await expect(page.getByTestId('search-input')).toHaveValue('What is GitBook?'); + await expect(page.getByTestId('search-ask-answer')).toBeVisible(); + }, + }, + { + name: 'Ask - AI Mode: Assistant - Complete flow', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Assistant, + }, + }), + screenshot: false, + run: async (page) => { + const searchInput = page.locator('css=[data-testid="search-input"]'); + + // Focus search input, expecting recommended questions + await searchInput.focus(); + await expect(page.getByTestId('search-results')).toBeVisible(); + const recommendedQuestions = await page + .getByTestId('search-recommended-question') + .all(); + await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions + + // Fill search input, expecting AI search option + await searchInput.fill('What is gitbook?'); + const aiSearchResult = page.getByTestId('search-ask-question'); + await expect(aiSearchResult).toBeVisible(); + await aiSearchResult.click(); + await expect(page.getByTestId('ai-chat')).toBeVisible(); + }, + }, + { + name: 'Ask - AI Mode: Assistant - Keyboard shortcut', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Assistant, + }, + }), + screenshot: false, + run: async (page) => { + await page.keyboard.press('ControlOrMeta+J'); + await expect(page.getByTestId('ai-chat')).toBeVisible(); + await expect(page.getByTestId('ai-chat-input')).toBeFocused(); + }, + }, + { + name: 'Ask - AI Mode: Assistant - Button', + url: getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Assistant, + }, + }), + screenshot: false, + run: async (page) => { + await page.getByTestId('ai-chat-button').click(); + await expect(page.getByTestId('ai-chat')).toBeVisible(); + await expect(page.getByTestId('ai-chat-input')).toBeFocused(); + }, + }, + { + name: 'Ask - AI Mode: Assistant - URL query (Initial)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Assistant, + }, + })}&ask=`, + screenshot: false, + run: async (page) => { + await expect(page.getByTestId('search-input')).not.toBeFocused(); + await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?'); + await expect(page.getByTestId('ai-chat')).toBeVisible(); + await expect(page.getByTestId('ai-chat-input')).toBeFocused(); + }, + }, + { + name: 'Ask - AI Mode: Assistant - URL query (Results)', + url: `${getCustomizationURL({ + ai: { + mode: CustomizationAIMode.Assistant, + }, + })}&ask=What+is+GitBook%3F`, + screenshot: false, + run: async (page) => { + await expect(page.getByTestId('search-input')).not.toBeFocused(); + await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?'); + await expect(page.getByTestId('ai-chat')).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByTestId('ai-chat-message').first()).toHaveText( + 'What is GitBook?' + ); + }, + }, +]; + const testCases: TestsCase[] = [ { name: 'GitBook Site (Single Variant)', @@ -53,34 +299,7 @@ const testCases: TestsCase[] = [ ); }, }, - { - name: 'Search', - url: '?q=', - screenshot: false, - run: async (page) => { - await expect(page.getByTestId('search-results')).toBeVisible(); - const allItems = await page.getByTestId('search-result-item').all(); - // Expect at least 3 questions - await expect(allItems.length).toBeGreaterThan(2); - }, - }, - { - name: 'Search Results', - url: '?q=gitbook', - run: async (page) => { - await expect(page.getByTestId('search-results')).toBeVisible(); - }, - }, - { - name: 'AI Search', - url: '?q=What+is+GitBook%3F&ask=true', - run: async (page) => { - await expect(page.getByTestId('search-ask-answer')).toBeVisible({ - timeout: 15_000, - }); - }, - screenshot: false, - }, + ...searchTestCases, { name: 'Not found', url: 'content-not-found', @@ -279,34 +498,7 @@ const testCases: TestsCase[] = [ url: '', run: waitForCookiesDialog, }, - { - name: 'Search', - url: '?q=', - screenshot: false, - run: async (page) => { - await expect(page.getByTestId('search-results')).toBeVisible(); - const allItems = await page.getByTestId('search-result-item').all(); - // Expect at least 3 questions - await expect(allItems.length).toBeGreaterThan(2); - }, - }, - { - name: 'Search Results', - url: '?q=gitbook', - run: async (page) => { - await expect(page.getByTestId('search-results')).toBeVisible(); - }, - }, - { - name: 'AI Search', - url: '?q=What+is+GitBook%3F&ask=true', - run: async (page) => { - await expect(page.getByTestId('search-ask-answer')).toBeVisible({ - timeout: 15_000, - }); - }, - screenshot: false, - }, + ...searchTestCases, { name: 'Not found', url: 'content-not-found', diff --git a/packages/gitbook/src/components/AI/useAIChat.tsx b/packages/gitbook/src/components/AI/useAIChat.tsx index 056d2e50b5..5a9736e152 100644 --- a/packages/gitbook/src/components/AI/useAIChat.tsx +++ b/packages/gitbook/src/components/AI/useAIChat.tsx @@ -5,6 +5,7 @@ import * as zustand from 'zustand'; import { AIMessageRole } from '@gitbook/api'; import * as React from 'react'; import { useTrackEvent } from '../Insights'; +import { useSearch } from '../Search'; import { streamAIChatResponse } from './server-actions'; import { useAIMessageContextRef } from './useAIMessageContext'; @@ -56,20 +57,15 @@ export type AIChatState = { export type AIChatController = { /** Open the dialog */ open: () => void; - /** Close the dialog */ close: () => void; - /** Post a message to the session */ - postMessage: (input: { - /** The message to post to the session. it can be markdown formatted. */ - message: string; - }) => void; - + postMessage: (input: { message: string }) => void; /** Clear the conversation */ clear: () => void; }; +// Global state store for AI chat const globalState = zustand.create<{ state: AIChatState; setState: (fn: (state: AIChatState) => Partial) => void; @@ -98,112 +94,200 @@ export function useAIChatState(): AIChatState { /** * Get the controller to interact with the AI chat. + * Integrates with search state to synchronize ?ask= parameter. */ export function useAIChatController(): AIChatController { const messageContextRef = useAIMessageContextRef(); const setState = zustand.useStore(globalState, (state) => state.setState); const trackEvent = useTrackEvent(); + const [searchState, setSearchState] = useSearch(true); - return React.useMemo(() => { - return { - open: () => setState((state) => ({ ...state, opened: true })), - close: () => setState((state) => ({ ...state, opened: false })), - clear: () => - setState((state) => ({ - opened: state.opened, - loading: false, - messages: [], - query: null, - followUpSuggestions: [], + // Open AI chat and sync with search state + const onOpen = React.useCallback(() => { + const { messages } = globalState.getState().state; + setState((state) => ({ ...state, opened: true })); + + // Update search state to show ask mode with first message or current ask value + setSearchState((prev) => ({ + ask: prev?.ask ?? messages[0]?.query ?? '', + query: prev?.query ?? null, + global: prev?.global ?? false, + open: false, // Close search popover when opening chat + })); + }, [setState, setSearchState]); + + // Close AI chat and clear ask parameter + const onClose = React.useCallback(() => { + setState((state) => ({ ...state, opened: false })); + + // Clear ask parameter but keep other search state + setSearchState((prev) => ({ + ask: null, + query: prev?.query ?? null, + global: prev?.global ?? false, + open: false, + })); + }, [setState, setSearchState]); + + // Post a message to the AI chat + const onPostMessage = React.useCallback( + async (input: { message: string }) => { + const { messages } = globalState.getState().state; + + // For first message, update the ask parameter in URL + if (messages.length === 0) { + setSearchState((prev) => ({ + ask: input.message, + query: prev?.query ?? null, + global: prev?.global ?? false, + open: false, + })); + } + + trackEvent({ type: 'ask_question', query: input.message }); + + // Add user message and placeholder for AI response + setState((state) => { + return { + ...state, + messages: [ + ...state.messages, + { + role: AIMessageRole.User, + content: input.message, + query: input.message, + }, + { + role: AIMessageRole.Assistant, + content: null, // Placeholder for streaming response + }, + ], + query: input.message, responseId: null, + followUpSuggestions: [], + loading: true, error: false, - })), - postMessage: async (input: { message: string }) => { - trackEvent({ type: 'ask_question', query: input.message }); - setState((state) => { - return { - ...state, - messages: [ - ...state.messages, - { - // TODO: how to handle markdown here? - // to avoid rendering as plain text - role: AIMessageRole.User, - content: input.message, - }, - { - role: AIMessageRole.Assistant, - content: null, - }, - ], - query: input.message, - followUpSuggestions: [], - loading: true, - error: false, - }; + }; + }); + + try { + const stream = await streamAIChatResponse({ + message: input.message, + messageContext: messageContextRef.current, + previousResponseId: globalState.getState().state.responseId ?? undefined, }); - try { - const stream = await streamAIChatResponse({ - message: input.message, - messageContext: messageContextRef.current, - previousResponseId: globalState.getState().state.responseId ?? undefined, - }); - - for await (const data of stream) { - if (!data) continue; - - const event = data.event; - - switch (event.type) { - case 'response_finish': { - setState((state) => ({ - ...state, - responseId: event.responseId, - // Mark as not loading when the response is finished - // Even if the stream might continue as we receive 'response_followup_suggestion' - loading: false, - error: false, - })); - break; - } - case 'response_followup_suggestion': { - setState((state) => ({ - ...state, - followUpSuggestions: [ - ...state.followUpSuggestions, - ...event.suggestions, - ], - })); - break; - } - } + // Process streaming response + for await (const data of stream) { + if (!data) continue; + + const event = data.event; - setState((state) => ({ - ...state, - messages: [ - ...state.messages.slice(0, -1), - { - role: AIMessageRole.Assistant, - content: data.content, - }, - ], - })); + switch (event.type) { + case 'response_finish': { + setState((state) => ({ + ...state, + responseId: event.responseId, + // Mark as not loading when the response is finished + // Even if the stream might continue as we receive 'response_followup_suggestion' + loading: false, + error: false, + })); + break; + } + case 'response_followup_suggestion': { + setState((state) => ({ + ...state, + followUpSuggestions: [ + ...state.followUpSuggestions, + ...event.suggestions, + ], + })); + break; + } } + // Update the assistant message with streamed content setState((state) => ({ ...state, - loading: false, - error: false, - })); - } catch { - setState((state) => ({ - ...state, - loading: false, - error: true, + messages: [ + ...state.messages.slice(0, -1), + { + role: AIMessageRole.Assistant, + content: data.content, + }, + ], })); } - }, + + setState((state) => ({ + ...state, + loading: false, + error: false, + })); + } catch { + setState((state) => ({ + ...state, + loading: false, + error: true, + })); + } + }, + [messageContextRef.current, setState, setSearchState, trackEvent] + ); + + // Clear the conversation and reset ask parameter + const onClear = React.useCallback(() => { + setState((state) => ({ + opened: state.opened, + loading: false, + messages: [], + query: null, + followUpSuggestions: [], + responseId: null, + error: false, + })); + + // Reset ask parameter to empty string (keeps chat open but clears content) + setSearchState((prev) => ({ + ask: '', + query: prev?.query ?? null, + global: prev?.global ?? false, + open: false, + })); + }, [setState, setSearchState]); + + // Auto-trigger AI chat when ?ask= parameter appears in URL + React.useEffect(() => { + const hasNoAsk = searchState?.ask === undefined || searchState?.ask === null; + const hasQuery = searchState?.query !== null; + + // Don't trigger if we have a regular search query active + if (hasNoAsk) return; + if (hasQuery && searchState.open === false) return; + + // Open the chat when ask parameter appears + onOpen(); + + // Auto-post the first message if ask has content and no messages exist yet + if (searchState?.ask?.trim()) { + const { messages } = globalState.getState().state; + if ( + // Post new message if it's different from the last user message + messages.filter((m) => m.role === AIMessageRole.User).at(-1)?.query !== + searchState?.ask?.trim() + ) { + onPostMessage({ message: searchState.ask.trim() }); + } + } + }, [searchState?.ask, searchState?.query, searchState?.open, onOpen, onPostMessage]); + + return React.useMemo(() => { + return { + open: onOpen, + close: onClose, + clear: onClear, + postMessage: onPostMessage, }; - }, [messageContextRef, setState, trackEvent]); + }, [onOpen, onClose, onClear, onPostMessage]); } diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index 0f8cb97408..39e2d8042d 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -104,10 +104,11 @@ export function AIChatWindow(props: { observer.observe(inputRef.current); } return () => observer.disconnect(); - }, [chat.opened]); + }, []); return (
diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index 3efa6a4d86..d5f4786acf 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -20,6 +20,7 @@ export function AIChatButton(props: { trademark: boolean }) { return (