From cced4f0d786252424f0337dbb121e127771e08c3 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Fri, 9 May 2025 17:38:54 +0200 Subject: [PATCH 1/8] Initial styling --- .../src/components/Search/HighlightQuery.tsx | 3 +- .../src/components/Search/SearchAskAnswer.tsx | 4 +- .../src/components/Search/SearchModal.tsx | 110 ++++++------ .../Search/SearchPageResultItem.tsx | 47 +++--- .../Search/SearchQuestionResultItem.tsx | 36 ++-- .../src/components/Search/SearchResults.tsx | 159 ++++++++---------- .../Search/SearchSectionResultItem.tsx | 32 ++-- packages/gitbook/src/intl/translations/en.ts | 1 + 8 files changed, 192 insertions(+), 200 deletions(-) diff --git a/packages/gitbook/src/components/Search/HighlightQuery.tsx b/packages/gitbook/src/components/Search/HighlightQuery.tsx index f0b32d74b0..4ad4140033 100644 --- a/packages/gitbook/src/components/Search/HighlightQuery.tsx +++ b/packages/gitbook/src/components/Search/HighlightQuery.tsx @@ -18,8 +18,7 @@ export function HighlightQuery(props: { 'text-bold', 'bg-primary', 'text-contrast-primary', - 'px-0.5', - '-mx-0.5', + 'px-1', 'py-0.5', 'rounded', 'straight-corners:rounded-sm', diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index 8a7dce493d..8b0c80393a 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -94,7 +94,7 @@ export function SearchAskAnswer(props: { query: string }) { ); return ( -
+ <> {askState?.type === 'answer' ? ( @@ -104,7 +104,7 @@ export function SearchAskAnswer(props: { query: string }) {
{t(language, 'search_ask_error')}
) : null} {askState?.type === 'loading' ? loading : null} -
+ ); } diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index bdc747241a..6f072e5ea0 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -1,6 +1,4 @@ 'use client'; - -import { Icon } from '@gitbook/icons'; import { AnimatePresence, motion } from 'framer-motion'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -219,8 +217,9 @@ function SearchModalBody( 'flex', 'flex-col', 'bg-tint-base', - 'max-w-prose', + 'max-w-screen-lg', 'mx-auto', + 'min-h-[30dvh]', 'max-h-[70dvh]', 'w-full', 'rounded-lg', @@ -236,69 +235,72 @@ function SearchModalBody( event.stopPropagation(); }} > -
-
- -
+
- + + {isMultiVariants ? : null} +
+
+ +
+ - {isMultiVariants ? : null} +
+
+
- {!state.ask || !withAsk ? ( - - ) : null} - {normalizedQuery && state.ask && withAsk ? ( - - ) : null} ); } diff --git a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx index 6b4d0e1c0e..bc7b165513 100644 --- a/packages/gitbook/src/components/Search/SearchPageResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchPageResultItem.tsx @@ -2,7 +2,9 @@ import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; import React from 'react'; -import { Link } from '../primitives'; +import { useLanguage } from '@/intl/client'; +import { tString } from '@/intl/translate'; +import { Button, Link } from '../primitives'; import { HighlightQuery } from './HighlightQuery'; import type { ComputedPageResult } from './server-actions'; @@ -14,6 +16,7 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt }, ref: React.Ref ) { + const language = useLanguage(); const { query, item, active } = props; const breadcrumbs = @@ -34,16 +37,19 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt 'flex-row', 'items-center', 'p-4', - 'border-t', - 'border-tint-subtle', - 'first:border-none', + 'rounded-lg', + 'straight-corners:rounded-none', 'text-base', 'font-medium', + 'text-tint-strong', 'hover:bg-tint-hover', 'group', - active - ? ['is-active', 'bg-primary', 'text-contrast-primary', 'hover:bg-primary-hover'] - : null + active && [ + 'is-active', + 'bg-primary', + 'text-primary-strong', + 'hover:bg-primary-hover', + ] )} insights={{ type: 'search_open_result', @@ -56,8 +62,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt >
@@ -65,7 +71,8 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
-
- -
+ ) : ( + + )} ); }); diff --git a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx index 83f585c82b..f58a54d5fa 100644 --- a/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchQuestionResultItem.tsx @@ -1,10 +1,10 @@ import { Icon } from '@gitbook/icons'; import React from 'react'; -import { t, useLanguage } from '@/intl/client'; +import { t, tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -import { Link } from '../primitives'; +import { Button, Link } from '../primitives'; import { useSearchLink } from './useSearch'; export const SearchQuestionResultItem = React.forwardRef(function SearchQuestionResultItem( @@ -28,14 +28,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion className={tcls( 'flex', 'px-4', - recommended ? ['py-2', 'text-tint'] : 'py-4', + 'py-2', + 'text-tint', + 'rounded-lg', + 'straight-corners:rounded-none', 'hover:bg-tint-hover', - 'first:mt-0', - 'last:pb-3', + 'gap-4', active && [ 'is-active', 'bg-primary', - 'text-contrast-primary', + 'text-primary-strong', 'hover:bg-primary-hover', ] )} @@ -50,7 +52,6 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion 'size-4', 'shrink-0', 'mt-1.5', - 'mr-4', active ? ['text-primary'] : ['text-tint-subtle'] )} /> @@ -66,19 +67,16 @@ export const SearchQuestionResultItem = React.forwardRef(function SearchQuestion )}
-
+ {active ? ( +
); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 1eca294d48..f5195544f7 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -44,7 +44,6 @@ let cachedRecommendedQuestions: null | ResultType[] = null; */ export const SearchResults = React.forwardRef(function SearchResults( props: { - children?: React.ReactNode; query: string; global: boolean; withAsk: boolean; @@ -52,7 +51,7 @@ export const SearchResults = React.forwardRef(function SearchResults( }, ref: React.Ref ) { - const { children, query, withAsk, global, onSwitchToAsk } = props; + const { query, withAsk, global, onSwitchToAsk } = props; const language = useLanguage(); const trackEvent = useTrackEvent(); @@ -150,12 +149,7 @@ export const SearchResults = React.forwardRef(function SearchResults( }; }, [query, global, withAsk, trackEvent]); - const results: ResultType[] = React.useMemo(() => { - if (!withAsk) { - return resultsState.results; - } - return withQuestionResult(resultsState.results, query); - }, [resultsState.results, query, withAsk]); + const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]); React.useEffect(() => { if (!query) { @@ -216,92 +210,87 @@ export const SearchResults = React.forwardRef(function SearchResults( if (resultsState.fetching) { return ( -
- +
+
); } const noResults = ( -
+
{t(language, 'search_no_results', query)}
); - return ( -
- {children} - {results.length === 0 ? ( - query ? ( - noResults - ) : null - ) : ( - <> -
- {results.map((item, index) => { - switch (item.type) { - case 'page': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - /> - ); - } - case 'question': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - question={query} - active={index === cursor} - onClick={onSwitchToAsk} - /> - ); - } - case 'recommended-question': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - question={item.question} - active={index === cursor} - onClick={onSwitchToAsk} - recommended - /> - ); - } - case 'section': { - return ( - { - refs.current[index] = ref; - }} - key={item.id} - query={query} - item={item} - active={index === cursor} - /> - ); - } - default: - assertNever(item); - } - })} -
- {!results.some((result) => result.type !== 'question') && noResults} - - )} -
+ return results.length === 0 ? ( + query ? ( + noResults + ) : null + ) : ( + <> +
+ {results.map((item, index) => { + switch (item.type) { + case 'page': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + query={query} + item={item} + active={index === cursor} + /> + ); + } + case 'question': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + question={query} + active={index === cursor} + onClick={onSwitchToAsk} + /> + ); + } + case 'recommended-question': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + question={item.question} + active={index === cursor} + onClick={onSwitchToAsk} + recommended + /> + ); + } + case 'section': { + return ( + { + refs.current[index] = ref; + }} + key={item.id} + query={query} + item={item} + active={index === cursor} + /> + ); + } + default: + assertNever(item); + } + })} +
+ {!results.some((result) => result.type !== 'question') && noResults} + ); }); diff --git a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx index ee2daa1ffb..b754ff93b3 100644 --- a/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx +++ b/packages/gitbook/src/components/Search/SearchSectionResultItem.tsx @@ -3,7 +3,8 @@ import React from 'react'; import { tcls } from '@/lib/tailwind'; -import { Link } from '../primitives'; +import { tString, useLanguage } from '@/intl/client'; +import { Button, Link } from '../primitives'; import { HighlightQuery } from './HighlightQuery'; import type { ComputedSectionResult } from './server-actions'; @@ -16,13 +17,15 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe ref: React.Ref ) { const { query, item, active } = props; + const language = useLanguage(); return ( ) : null}
-
- -
+ {active ? ( +
); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index f5195544f7..8476bfe3ff 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; +import { motion } from 'framer-motion'; import { useTrackEvent } from '../Insights'; import { Loading } from '../primitives'; import { SearchPageResultItem } from './SearchPageResultItem'; @@ -18,6 +19,7 @@ import { searchSiteSpaceContent, streamRecommendedQuestions, } from './server-actions'; +import { useSearch } from './useSearch'; export interface SearchResultsRef { moveUp(): void; @@ -61,6 +63,7 @@ export const SearchResults = React.forwardRef(function SearchResults( }>({ results: [], fetching: true }); const [cursor, setCursor] = React.useState(null); const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); + const [searchState, setSearchState] = useSearch(); React.useEffect(() => { if (!query) { @@ -157,9 +160,12 @@ export const SearchResults = React.forwardRef(function SearchResults( setCursor(null); } else if (results.length > 0) { // Auto-focus the first result + setSearchState((prev) => (prev ? { ...prev, mode: 'both' } : null)); setCursor(0); + } else if (results.length === 0 && !resultsState.fetching && !searchState?.manual) { + setSearchState((prev) => (prev ? { ...prev, mode: 'chat' } : null)); } - }, [results, query]); + }, [results, query, setSearchState, resultsState.fetching, searchState?.manual]); // Scroll to the active result. React.useEffect(() => { @@ -210,9 +216,12 @@ export const SearchResults = React.forwardRef(function SearchResults( if (resultsState.fetching) { return ( -
+ -
+ ); } @@ -293,16 +302,3 @@ export const SearchResults = React.forwardRef(function SearchResults( ); }); - -/** - * Add a "Ask " item at the top of the results list. - */ -function withQuestionResult(results: ResultType[], query: string): ResultType[] { - const without = results.filter((result) => result.type !== 'question'); - - if (query.length === 0) { - return without; - } - - return [{ type: 'question', id: 'question', query }, ...(without ?? [])]; -} diff --git a/packages/gitbook/src/components/Search/useSearch.ts b/packages/gitbook/src/components/Search/useSearch.ts index 8f3ecfaec5..d4e20b3d4c 100644 --- a/packages/gitbook/src/components/Search/useSearch.ts +++ b/packages/gitbook/src/components/Search/useSearch.ts @@ -1,19 +1,21 @@ -import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'; +import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; import React from 'react'; import type { LinkProps } from '../primitives'; export interface SearchState { query: string; - ask: boolean; global: boolean; + mode: 'results' | 'chat' | 'both'; + manual?: boolean; } // KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render. const keyMap = { q: parseAsString, - ask: parseAsBoolean, + mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'), global: parseAsBoolean, + manual: parseAsBoolean, }; export type UpdateSearchState = ( @@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] { return null; } - return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global }; + return { + query: rawState.q, + mode: rawState.mode, + global: !!rawState.global, + manual: !!rawState.manual, + }; }, [rawState]); const stateRef = React.useRef(state); @@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] { if (update === null) { return setRawState({ q: null, - ask: null, + mode: null, global: null, + manual: null, }); } return setRawState({ q: update.query, - ask: update.ask ? true : null, + mode: update.mode, global: update.global ? true : null, + manual: update.manual ? true : null, }); }, [setRawState] @@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial) => LinkProps { (query) => { const searchParams = new URLSearchParams(); searchParams.set('q', query.query ?? ''); - query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask'); + query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode'); query.global ? searchParams.set('global', 'on') : searchParams.delete('global'); + searchParams.delete('manual'); return { href: `?${searchParams.toString()}`, prefetch: false, @@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial) => LinkProps { event.preventDefault(); setSearch((prev) => ({ query: '', - ask: false, + mode: 'both', global: false, ...(prev ?? {}), ...query, From 31bfe77f743b492ce8bba599dfcbc4d8cc129261 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Mon, 12 May 2025 15:23:52 +0200 Subject: [PATCH 3/8] Create Chat component --- .../server-actions/streamAskAIAnswer.ts | 166 ++++++++++++++++++ .../src/components/Search/SearchChat.tsx | 67 +++++++ .../src/components/Search/SearchModal.tsx | 6 +- .../src/components/Search/server-actions.tsx | 129 +++++++++++++- 4 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts create mode 100644 packages/gitbook/src/components/Search/SearchChat.tsx diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts new file mode 100644 index 0000000000..88abfe0198 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamAskAIAnswer.ts @@ -0,0 +1,166 @@ +'use server'; +import { filterOutNullable } from '@/lib/typescript'; +import { getV1BaseContext } from '@/lib/v1'; +import { isV2 } from '@/lib/v2'; +import { AIMessageRole } from '@gitbook/api'; +import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; +import { getServerActionBaseContext } from '@v2/lib/server-actions'; +import { z } from 'zod'; +import { streamGenerateObject } from './api'; + +/** + * Get a summary of a page, in the context of another page + */ +export async function* streamLinkPageSummary({ + currentSpaceId, + currentPageId, + targetSpaceId, + targetPageId, + linkPreview, + linkTitle, + visitedPages, +}: { + currentSpaceId: string; + currentPageId: string; + currentPageTitle: string; + targetSpaceId: string; + targetPageId: string; + linkPreview?: string; + linkTitle?: string; + visitedPages?: Array<{ spaceId: string; pageId: string }>; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const { stream } = await streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + highlight: z + .string() + .describe('The reason why the user should read the target page.'), + // questions: z.array(z.string().describe('The questions to sea')).max(3), + }), + messages: [ + { + role: AIMessageRole.Developer, + content: `# 1. Role +You are a contextual fact extractor. Your job is to find the exact fact from the linked page that directly answers the implied question in the current paragraph. + +# 2. Task +Extract a contextually-relevant fact that: +- Directly answers the specific need or question implied by the link's placement +- States a capability, limitation, or specification from the target page +- Connects precisely to the user's current paragraph or sentence +- Completes the user's understanding based on what they're currently reading + +# 3. Instructions +1. First, identify the exact need, question, or gap in the current paragraph where the link appears +2. Find the specific fact in the target page that addresses this exact contextual need +3. Ensure the fact relates directly to the context of the paragraph containing the link +4. Avoid ALL instructional language including words like "use", "click", "select", "create" +5. Keep it under 30 words, factual and declarative about what EXISTS or IS TRUE`, + }, + { + role: AIMessageRole.Developer, + content: `# 4. Current page +The content of the current page is:`, + attachments: [ + { + type: 'page' as const, + spaceId: currentSpaceId, + pageId: currentPageId, + }, + ], + }, + ...(visitedPages + ? [ + { + role: AIMessageRole.Developer, + content: '# 5. Previous pages', + }, + ...visitedPages.map(({ spaceId, pageId }) => ({ + role: AIMessageRole.Developer, + content: `## Page ${pageId}`, + attachments: [ + { + type: 'page' as const, + spaceId, + pageId, + }, + ], + })), + ] + : []), + { + role: AIMessageRole.Developer, + content: `# 6. Target page +The content of the target page is:`, + attachments: [ + { + type: 'page' as const, + spaceId: targetSpaceId, + pageId: targetPageId, + }, + ], + }, + { + role: AIMessageRole.Developer, + content: `# 7. Link preview +The content of the link preview is: +> ${linkPreview} +> Page ID: ${targetPageId}`, + }, + { + role: AIMessageRole.Developer, + content: `# 8. Guidelines & Examples +ALWAYS: +- ALWAYS choose facts that directly fulfill the contextual need where the link appears +- ALWAYS connect target page information specifically to the current paragraph context +- ALWAYS focus on the gap in knowledge that the link is meant to fill +- ALWAYS consider user's navigation history to ensure contextual continuity +- ALWAYS use action verbs like "click", "select", "use", "create", "enable" + +NEVER: +- NEVER include ANY unspecifc language like "learn", "how to", "discover", etc. State the fact directly. +- NEVER select general facts unrelated to the specific link context +- NEVER ignore the specific context where the link appears +- NEVER repeat the same fact in different words + +## Examples +Current paragraph: "When organizing content, headings are limited to 3 levels. For more advanced editing, you can use (multiple select)[/multiple-select] to move multiple blocks at once." +Preview: "Multiple Select: Select multiple content blocks at once." +✓ "Shift selects content between two points, useful for reorganizing your current heading structure." +✗ "Shift and Ctrl/Cmd keys are the modifiers for selecting multiple blocks." + +Current paragraph: "Most changes can be published directly, but for major revisions, if you want others to review changes before publishing, create a (change request)[/change-requests]." +Preview: "Change Requests: Collaborative content editing workflow." +✓ "Each reviewer's approval is tracked separately, with specific change highlighting for your major revisions." +✗ "Each reviewer receives an email notification and can approve or request changes." + +Current paragraph: "Your team mentioned issues with conflicting edits. Need to collaborate in real-time? You can use (live edit mode)[/live-edit]." +Preview: "Live Edit: Real-time collaborative editing." +✓ "Teams with GitHub repositories (like yours) cannot use this feature due to sync limitations." +✗ "Incompatible with GitHub/GitLab sync and requires specific visibility settings."`, + }, + { + role: AIMessageRole.User, + content: `I'm considering reading the link titled "${linkTitle}" pointing to page ${targetPageId}. Why should I read it? Relate it to the paragraph I'm currently reading.`, + }, + ].filter(filterOutNullable), + } + ); + + for await (const value of stream) { + const highlight = value.highlight; + if (!highlight) { + continue; + } + + yield highlight; + } +} diff --git a/packages/gitbook/src/components/Search/SearchChat.tsx b/packages/gitbook/src/components/Search/SearchChat.tsx new file mode 100644 index 0000000000..778b43e876 --- /dev/null +++ b/packages/gitbook/src/components/Search/SearchChat.tsx @@ -0,0 +1,67 @@ +'use client'; +import { Icon } from '@gitbook/icons'; +import { motion } from 'framer-motion'; +import { useEffect, useState } from 'react'; +import { useVisitedPages } from '../Insights/useVisitedPages'; +import { streamAISearchSummary } from './server-actions'; + +export function SearchChat() { + // const currentPage = usePageContext(); + // const language = useLanguage(); + const visitedPages = useVisitedPages((state) => state.pages); + const [summary, setSummary] = useState(''); + const [responseId, setResponseId] = useState(null); + + useEffect(() => { + let cancelled = false; + + (async () => { + const stream = await streamAISearchSummary({ + visitedPages, + }); + + let generatedSummary = ''; + for await (const data of stream) { + if (cancelled) return; + + if ('responseId' in data && data.responseId !== undefined) { + setResponseId(data.responseId); + } + + if ('summary' in data && data.summary !== undefined) { + generatedSummary = data.summary; + setSummary(generatedSummary); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [visitedPages]); + + return ( + +
+ Summary of what you've read +
+ + {summary ? ( + summary + ) : ( +
+ {[...Array(9)].map((_, index) => ( +
+ ))} +
+ )} + + ); +} diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index 93ae7b791d..b8ed1daec4 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -9,8 +9,8 @@ import { tcls } from '@/lib/tailwind'; import { Button } from '../primitives/Button'; import { LoadingPane } from '../primitives/LoadingPane'; -import { SearchAskAnswer } from './SearchAskAnswer'; import { SearchAskProvider, useSearchAskState } from './SearchAskContext'; +import { SearchChat } from './SearchChat'; import { SearchResults, type SearchResultsRef } from './SearchResults'; import { SearchScopeToggle } from './SearchScopeToggle'; import { type SearchState, type UpdateSearchState, useSearch } from './useSearch'; @@ -317,7 +317,7 @@ function SearchModalBody( key="chat" layout className={tcls( - '-col-end-1 flex items-start gap-4 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle p-8 max-md:border-t md:row-start-2 md:border-l', + 'md:-col-end-1 flex items-start gap-4 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle p-8 max-md:border-t md:row-start-2 md:border-l', state.mode === 'chat' && 'md:col-start-1' )} initial={{ width: 0 }} @@ -338,7 +338,7 @@ function SearchModalBody( }} /> ) : null} - + ) : null} diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index b45bc0aba1..07d1e9042d 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -4,15 +4,16 @@ import { resolvePageId } from '@/lib/pages'; import { findSiteSpaceById, getSiteStructureSections } from '@/lib/sites'; import { filterOutNullable } from '@/lib/typescript'; import { getV1BaseContext } from '@/lib/v1'; -import type { - RevisionPage, - SearchAIAnswer, - SearchAIRecommendedQuestionStream, - SearchPageResult, - SearchSpaceResult, - SiteSection, - SiteSectionGroup, - Space, +import { + AIMessageRole, + type RevisionPage, + type SearchAIAnswer, + type SearchAIRecommendedQuestionStream, + type SearchPageResult, + type SearchSpaceResult, + type SiteSection, + type SiteSectionGroup, + type Space, } from '@gitbook/api'; import type { GitBookBaseContext, GitBookSiteContext } from '@v2/lib/context'; import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions'; @@ -24,6 +25,8 @@ import { isV2 } from '@/lib/v2'; import type { IconName } from '@gitbook/icons'; import { throwIfDataError } from '@v2/lib/data'; import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; +import { z } from 'zod'; +import { streamGenerateObject } from '../Adaptive/server-actions/api'; import { DocumentView } from '../DocumentView'; export type OrderedComputedResult = ComputedPageResult | ComputedSectionResult; @@ -410,3 +413,111 @@ async function transformSitePageResult( return [page, ...pageSections]; } + +/** + * Get an AI-generated answer to a search query. + */ +export async function* streamAISearchSummary({ + visitedPages, +}: { + visitedPages: { spaceId: string; pageId: string }[]; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const { stream, response } = await streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + summary: z + .string() + .describe( + 'A summary of the most important information the user has learned from the provided context.' + ), + }), + messages: [ + { + role: AIMessageRole.Developer, + content: + 'Summarise the most important information the user has learned from the provided context. Be concise and focus on facts. Do not add commentary, adjectives or other empty descriptors.', + attachments: visitedPages.map(({ spaceId, pageId }) => ({ + type: 'page' as const, + spaceId, + pageId, + })), + }, + ].filter(filterOutNullable), + } + ); + + // Get the responseId asynchronously in the background + let responseId: string | null = null; + const responseIdPromise = response + .then((r) => { + responseId = r.responseId; + }) + .catch((error) => { + console.error('Error getting responseId:', error); + }); + + for await (const value of stream) { + const summary = value.summary; + if (!summary) { + continue; + } + + yield { summary }; + } + + // Wait for the responseId to be available and yield one final time + await responseIdPromise; + yield { responseId }; +} + +/** + * Get an AI-generated answer to a search query. + */ +export async function* streamAISearchAnswer({ + question, +}: { + question: string; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const { stream } = await streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + answer: z.string().describe('The answer to the question.'), + }), + messages: [ + { + role: AIMessageRole.Developer, + content: `Answer the following question using only the provided context below. Format the answer in Markdown. If you cannot answer the question using the context provided, provide an empty string. Always list related follow-up questions using the provided context. Check first that you can answer the question given the provided context before listing it as a follow-up question. If you can't answer a question, don't include it in the follow up questions. If there is no provided context, do not list follow-up questions. List the sources used to answer the question in the "sources" field. Only list the sources that were directly used for the content of the answer.`, + }, + { + role: AIMessageRole.User, + content: `Question: ${question}`, + }, + ].filter(filterOutNullable), + } + ); + + for await (const value of stream) { + const highlight = value.answer; + if (!highlight) { + continue; + } + + yield highlight; + } +} From 5a410feda08e6c8d5874df47f33cc76d5d7d2fd4 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 13 May 2025 10:50:10 +0200 Subject: [PATCH 4/8] Add chat component (non-functional) --- packages/gitbook-v2/src/lib/data/types.ts | 2 + .../components/Adaptive/server-actions/api.ts | 8 +- .../src/components/Search/SearchChat.tsx | 187 +++++++++++++++--- .../src/components/Search/SearchModal.tsx | 24 +-- .../Search/SearchQuestionResultItem.tsx | 1 - .../src/components/Search/SearchResults.tsx | 179 +++++++++-------- .../src/components/Search/server-actions.tsx | 41 +++- 7 files changed, 307 insertions(+), 135 deletions(-) diff --git a/packages/gitbook-v2/src/lib/data/types.ts b/packages/gitbook-v2/src/lib/data/types.ts index 178a0ba77d..0b6920523d 100644 --- a/packages/gitbook-v2/src/lib/data/types.ts +++ b/packages/gitbook-v2/src/lib/data/types.ts @@ -189,5 +189,7 @@ export interface GitBookDataFetcher { input: api.AIMessageInput[]; output: api.AIOutputFormat; model: api.AIModel; + tools?: api.AIToolCapabilities; + previousResponseId?: string; }): AsyncGenerator; } diff --git a/packages/gitbook/src/components/Adaptive/server-actions/api.ts b/packages/gitbook/src/components/Adaptive/server-actions/api.ts index a1396987d7..1ea43e8a55 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/api.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/api.ts @@ -1,5 +1,10 @@ 'use server'; -import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api'; +import { + type AIMessageInput, + AIModel, + type AIStreamResponse, + type AIToolCapabilities, +} from '@gitbook/api'; import type { GitBookBaseContext } from '@v2/lib/context'; import { EventIterator } from 'event-iterator'; import type { MaybePromise } from 'p-map'; @@ -51,6 +56,7 @@ export async function streamGenerateObject( schema: z.ZodSchema; messages: AIMessageInput[]; model?: AIModel; + tools?: AIToolCapabilities; previousResponseId?: string; } ) { diff --git a/packages/gitbook/src/components/Search/SearchChat.tsx b/packages/gitbook/src/components/Search/SearchChat.tsx index 778b43e876..87b528b94d 100644 --- a/packages/gitbook/src/components/Search/SearchChat.tsx +++ b/packages/gitbook/src/components/Search/SearchChat.tsx @@ -1,15 +1,27 @@ 'use client'; +import { tcls } from '@/lib/tailwind'; +import { filterOutNullable } from '@/lib/typescript'; import { Icon } from '@gitbook/icons'; import { motion } from 'framer-motion'; import { useEffect, useState } from 'react'; import { useVisitedPages } from '../Insights/useVisitedPages'; -import { streamAISearchSummary } from './server-actions'; +import { Button } from '../primitives'; +import { isQuestion } from './isQuestion'; +import { streamAISearchAnswer, streamAISearchSummary } from './server-actions'; -export function SearchChat() { +export function SearchChat(props: { query: string }) { // const currentPage = usePageContext(); // const language = useLanguage(); + + const { query } = props; + const visitedPages = useVisitedPages((state) => state.pages); const [summary, setSummary] = useState(''); + const [messages, setMessages] = useState< + { role: string; content?: string; fetching?: boolean }[] + >([]); + const [followupQuestions, setFollowupQuestions] = useState(); + const [responseId, setResponseId] = useState(null); useEffect(() => { @@ -20,7 +32,6 @@ export function SearchChat() { visitedPages, }); - let generatedSummary = ''; for await (const data of stream) { if (cancelled) return; @@ -29,8 +40,7 @@ export function SearchChat() { } if ('summary' in data && data.summary !== undefined) { - generatedSummary = data.summary; - setSummary(generatedSummary); + setSummary(data.summary); } } })(); @@ -40,28 +50,155 @@ export function SearchChat() { }; }, [visitedPages]); + useEffect(() => { + let cancelled = false; + + if (query) { + setMessages([ + { + role: 'user', + content: query, + }, + { + role: 'assistant', + fetching: true, + }, + ]); + + (async () => { + const stream = await streamAISearchAnswer({ + question: query, + previousResponseId: responseId ?? undefined, + }); + + for await (const data of stream) { + if (cancelled) return; + + if ('responseId' in data && data.responseId !== undefined) { + setResponseId(data.responseId); + } + + if ('answer' in data && data.answer !== undefined) { + setMessages((prev) => [ + ...prev.slice(0, -1), + { role: 'assistant', content: data.answer, fetching: false }, + ]); + } + + if ('followupQuestions' in data && data.followupQuestions !== undefined) { + setFollowupQuestions(data.followupQuestions.filter(filterOutNullable)); + } + } + })(); + + return () => { + cancelled = true; + }; + } + }, [query, responseId]); + return ( - -
- Summary of what you've read -
- - {summary ? ( - summary - ) : ( -
- {[...Array(9)].map((_, index) => ( -
- ))} + +
+
+
+ Summary of what + you've read +
+ + {summary ? ( + summary + ) : ( +
+ {[...Array(9)].map((_, index) => ( +
+ ))} +
+ )} +
+ + {messages.map((message) => ( +
+ {message.role === 'user' ? ( +
+ You asked {isQuestion(query) ? '' : 'about'} +
+ ) : ( +
+ AI Answer +
+ )} + {message.fetching ? ( +
+ {[...Array(9)].map((_, index) => ( +
+ ))} +
+ ) : ( +
+ {message.content} +
+ )} +
+ ))} +
+ + {query ? ( +
+
+ {followupQuestions && followupQuestions.length > 0 && ( +
+ {followupQuestions?.map((question) => ( +
+ {question} +
+ ))} +
+ )} +
+ +
+
- )} + ) : null} ); } diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index b8ed1daec4..8d04feadab 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -6,8 +6,6 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; - -import { Button } from '../primitives/Button'; import { LoadingPane } from '../primitives/LoadingPane'; import { SearchAskProvider, useSearchAskState } from './SearchAskContext'; import { SearchChat } from './SearchChat'; @@ -220,8 +218,8 @@ function SearchModalBody( 'bg-tint-base', 'max-w-screen-lg', 'mx-auto', - 'min-h-[30dvh]', - 'max-h-[70dvh]', + // 'min-h-[50dvh]', + 'h-[70dvh]', 'w-full', 'rounded-lg', 'straight-corners:rounded-sm', @@ -317,28 +315,14 @@ function SearchModalBody( key="chat" layout className={tcls( - 'md:-col-end-1 flex items-start gap-4 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle p-8 max-md:border-t md:row-start-2 md:border-l', + 'md:-col-end-1 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle max-md:border-t md:row-start-2 md:border-l', state.mode === 'chat' && 'md:col-start-1' )} initial={{ width: 0 }} animate={{ width: '100%' }} exit={{ width: 0 }} > - {state.mode === 'chat' ? ( -
+ ) : null}
@@ -124,21 +167,24 @@ export function SearchChat(props: { query: string }) { )}
- {messages.map((message) => ( + {messages.map((message, index) => (
{message.role === 'user' ? (
- You asked {isQuestion(query) ? '' : 'about'} + {message.context ?? `You asked ${isQuestion(query) ? '' : 'about'}`}
) : (
- AI Answer + {' '} + {message.context ?? 'AI Answer'}
)} {message.fetching ? ( @@ -194,11 +240,17 @@ export function SearchChat(props: { query: string }) { icon="arrow-up" size="medium" className="shrink-0" + onClick={() => { + setMessages((prev) => [ + ...prev, + { role: 'user', content: 'Hello', fetching: false }, + ]); + }} />
) : null} -
+
); } diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index 8d04feadab..b0104c0d3f 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -234,98 +234,84 @@ function SearchModalBody( event.stopPropagation(); }} > -
+
-
- - {isMultiVariants ? : null} -
+ placeholder={tString( + language, + withAsk ? 'search_ask_input_placeholder' : 'search_input_placeholder' + )} + spellCheck="false" + autoComplete="off" + autoCorrect="off" + /> + {isMultiVariants ? : null} +
+
+
+
+
- - {state.mode !== 'chat' ? ( - - - - ) : null} - - {state.mode !== 'results' ? ( - - - - ) : null} - +
+ +
); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 37d7939074..b16675bd03 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -import { AnimatePresence, motion } from 'framer-motion'; +import { AnimatePresence } from 'framer-motion'; import { useTrackEvent } from '../Insights'; import { Loading } from '../primitives'; import { SearchPageResultItem } from './SearchPageResultItem'; @@ -19,7 +19,7 @@ import { searchSiteSpaceContent, streamRecommendedQuestions, } from './server-actions'; -import { useSearch } from './useSearch'; +import { type SearchState, useSearch } from './useSearch'; export interface SearchResultsRef { moveUp(): void; @@ -64,6 +64,57 @@ export const SearchResults = React.forwardRef(function SearchResults( const [cursor, setCursor] = React.useState(null); const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); const [searchState, setSearchState] = useSearch(); + const manualStateRef = React.useRef(false); + + React.useEffect(() => { + if (searchState?.manual !== undefined) { + manualStateRef.current = searchState.manual; + } + }, [searchState?.manual]); + + const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]); + + React.useEffect(() => { + console.log('Effect running with:', { + query, + resultsLength: results.length, + fetching: resultsState.fetching, + manual: searchState?.manual, + currentState: searchState, + }); + + // If manual is true, don't do any automatic mode changes + if (searchState?.manual) { + console.log('Skipping automatic mode changes because manual is true'); + return; + } + + if (!query) { + // Reset the cursor when there's no query + setCursor(null); + } else if (results.length > 0) { + // Auto-focus the first result + console.log('Setting mode to both'); + setSearchState((prev) => { + const newState: SearchState | null = prev + ? { ...prev, mode: 'both' as const } + : null; + console.log('New state will be:', newState); + return newState; + }); + setCursor(0); + } else if (results.length === 0 && !resultsState.fetching) { + // Only switch to chat mode if manual is false + console.log('Setting mode to chat'); + setSearchState((prev) => { + const newState: SearchState | null = prev + ? { ...prev, mode: 'chat' as const } + : null; + console.log('New state will be:', newState); + return newState; + }); + } + }, [results, query, setSearchState, resultsState.fetching, searchState?.manual]); React.useEffect(() => { if (!query) { @@ -152,21 +203,6 @@ export const SearchResults = React.forwardRef(function SearchResults( }; }, [query, global, withAsk, trackEvent]); - const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]); - - React.useEffect(() => { - if (!query) { - // Reset the cursor when there's no query - setCursor(null); - } else if (results.length > 0) { - // Auto-focus the first result - setSearchState((prev) => (prev ? { ...prev, mode: 'both' } : null)); - setCursor(0); - } else if (results.length === 0 && !resultsState.fetching && !searchState?.manual) { - setSearchState((prev) => (prev ? { ...prev, mode: 'chat' } : null)); - } - }, [results, query, setSearchState, resultsState.fetching, searchState?.manual]); - // Scroll to the active result. React.useEffect(() => { if (cursor === null || !refs.current[cursor]) { @@ -215,27 +251,15 @@ export const SearchResults = React.forwardRef(function SearchResults( ); const loading = ( - +
- +
); const noResults = ( - +
{t(language, 'search_no_results', query)} - +
); return ( @@ -245,13 +269,10 @@ export const SearchResults = React.forwardRef(function SearchResults( ) : query && results.length === 0 ? ( noResults ) : ( - {results.map((item, index) => { switch (item.type) { @@ -312,7 +333,7 @@ export const SearchResults = React.forwardRef(function SearchResults( assertNever(item); } })} - +
)} ); From e2b2468475c7c73ff51508da25e23267cd562627 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 21 May 2025 15:13:22 +0200 Subject: [PATCH 6/8] More layout tweaks --- .../src/components/Search/SearchChat.tsx | 69 +++++++++++-------- .../src/components/Search/SearchModal.tsx | 2 +- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchChat.tsx b/packages/gitbook/src/components/Search/SearchChat.tsx index d0017c4711..c209aca1bf 100644 --- a/packages/gitbook/src/components/Search/SearchChat.tsx +++ b/packages/gitbook/src/components/Search/SearchChat.tsx @@ -28,6 +28,8 @@ export function SearchChat(props: { query: string }) { const containerRef = useRef(null); const latestMessageRef = useRef(null); + const isExpanded = searchState?.mode === 'chat'; + useEffect(() => { let cancelled = false; @@ -65,6 +67,7 @@ export function SearchChat(props: { query: string }) { fetching: true, }, ]); + setFollowupQuestions([]); (async () => { const stream = await streamAISearchAnswer({ @@ -108,19 +111,15 @@ export function SearchChat(props: { query: string }) { }, [messages]); return ( -
+
{searchState?.mode === 'chat' ? ( -
+
) : null} -
-
+
+
Summary of what you've read @@ -172,9 +177,9 @@ export function SearchChat(props: { query: string }) { key={message.content} ref={index === messages.length - 1 ? latestMessageRef : null} className={tcls( - 'flex scroll-mt-20 scroll-mb-[100%] flex-col gap-1', + 'mx-auto flex w-full max-w-prose flex-col gap-1', message.role === 'user' && 'items-end gap-1 self-end', - index === messages.length - 1 && 'mb-[45vh]' + index === messages.length - 1 && 'min-h-full' )} > {message.role === 'user' ? ( @@ -209,25 +214,33 @@ export function SearchChat(props: { query: string }) { {message.content}
)} + + {index === messages.length - 1 && + followupQuestions && + followupQuestions.length > 0 && ( +
+ {followupQuestions?.map((question) => ( +
+ {question} +
+ ))} +
+ )}
))}
{query ? ( -
+
- {followupQuestions && followupQuestions.length > 0 && ( -
- {followupQuestions?.map((question) => ( -
- {question} -
- ))} -
- )}
Date: Wed, 21 May 2025 18:08:02 +0200 Subject: [PATCH 7/8] Added back and forth chat --- .../src/components/Search/SearchButton.tsx | 2 +- .../src/components/Search/SearchChat.tsx | 527 +++++++++++++----- .../src/components/Search/SearchModal.tsx | 34 +- .../src/components/Search/SearchResults.tsx | 35 +- .../src/components/Search/isQuestion.ts | 2 +- .../src/components/Search/server-actions.tsx | 2 +- 6 files changed, 418 insertions(+), 184 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchButton.tsx b/packages/gitbook/src/components/Search/SearchButton.tsx index ab8516d059..54377dbbcd 100644 --- a/packages/gitbook/src/components/Search/SearchButton.tsx +++ b/packages/gitbook/src/components/Search/SearchButton.tsx @@ -99,7 +99,7 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV ); } -function Shortcut() { +export function Shortcut() { const [operatingSystem, setOperatingSystem] = useState(null); useEffect(() => { diff --git a/packages/gitbook/src/components/Search/SearchChat.tsx b/packages/gitbook/src/components/Search/SearchChat.tsx index c209aca1bf..6289dcfea4 100644 --- a/packages/gitbook/src/components/Search/SearchChat.tsx +++ b/packages/gitbook/src/components/Search/SearchChat.tsx @@ -1,53 +1,258 @@ 'use client'; +import { useLanguage } from '@/intl/client'; +import { t } from '@/intl/translate'; import { tcls } from '@/lib/tailwind'; import { filterOutNullable } from '@/lib/typescript'; import { Icon } from '@gitbook/icons'; import { useEffect, useRef, useState } from 'react'; import { useVisitedPages } from '../Insights/useVisitedPages'; import { Button } from '../primitives'; +import { Shortcut } from './SearchButton'; import { isQuestion } from './isQuestion'; import { streamAISearchAnswer, streamAISearchSummary } from './server-actions'; import { useSearch } from './useSearch'; -export function SearchChat(props: { query: string }) { - // const currentPage = usePageContext(); - // const language = useLanguage(); +// Types +type Message = { + role: 'assistant' | 'user'; + content?: string; + context?: string; + fetching?: boolean; +}; - const { query } = props; +// Loading animation component +function LoadingAnimation() { + return ( +
+ {[...Array(9)].map((_, index) => ( +
+ ))} +
+ ); +} - const visitedPages = useVisitedPages((state) => state.pages); - const [summary, setSummary] = useState(''); - const [messages, setMessages] = useState< - { role: string; content?: string; context?: string; fetching?: boolean }[] - >([]); - const [followupQuestions, setFollowupQuestions] = useState(); +// Followup questions component +function FollowupQuestions({ + questions, + onQuestionClick, +}: { + questions: string[]; + onQuestionClick: (question: string) => void; +}) { + if (!questions || questions.length === 0) return null; - const [responseId, setResponseId] = useState(null); - const [searchState, setSearchState] = useSearch(); + return ( +
+ {questions.map((question) => ( + + ))} +
+ ); +} - const containerRef = useRef(null); - const latestMessageRef = useRef(null); +// Individual chat message component +function ChatMessage({ + message, +}: { + message: Message; +}) { + const language = useLanguage(); + const isUser = message.role === 'user'; - const isExpanded = searchState?.mode === 'chat'; + return ( +
+
+ {isUser ? ( + (message.context ?? + `You asked ${isQuestion(message.content ?? '') ? '' : 'about'}`) + ) : ( + <> + + {message.context ?? 'AI Answer'} + + )} +
+ + {message.fetching ? ( + + ) : !message.content ? ( +
{t(language, 'search_ask_no_answer')}
+ ) : ( +
+ {message.content} +
+ )} +
+ ); +} + +// Chat input component +function ChatInput({ + onSendMessage, + disabled, + inputRef, +}: { + onSendMessage: (message: string) => void; + disabled: boolean; + inputRef?: React.RefObject; +}) { + const [inputValue, setInputValue] = useState(''); + + const handleSend = () => { + if (!inputValue.trim()) return; + onSendMessage(inputValue); + setInputValue(''); + }; + + return ( +
+
+ setInputValue(e.target.value)} + disabled={disabled} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + {!disabled && ( +
+ +
+ )} +
+
+ ); +} + +// Custom hook for AI streaming +function useAIStream({ + question, + previousResponseId, +}: { + question?: string; + previousResponseId?: string; +}) { + const [response, setResponse] = useState<{ + content?: string; + responseId?: string; + followupQuestions?: string[]; + fetching: boolean; + }>({ + fetching: false, + }); useEffect(() => { + if (!question) return; + let cancelled = false; + setResponse({ fetching: true }); (async () => { - const stream = await streamAISearchSummary({ - visitedPages, - }); + try { + const stream = await streamAISearchAnswer({ + question, + previousResponseId, + }); + + for await (const rawData of stream) { + if (cancelled) break; + if (!rawData) continue; + + // Use type assertion to handle the data + const data = rawData as any; - for await (const data of stream) { - if (cancelled) return; + setResponse((prev) => { + const updated = { ...prev, fetching: false }; - if ('responseId' in data && data.responseId !== undefined) { - setResponseId(data.responseId); + if (data.responseId) { + updated.responseId = String(data.responseId); + } + + if (data.answer) { + updated.content = String(data.answer); + } + + if (data.followupQuestions) { + updated.followupQuestions = + data.followupQuestions.filter(filterOutNullable); + } + + return updated; + }); } + } catch (error) { + console.error('Error in AI stream:', error); + setResponse((prev) => ({ ...prev, fetching: false })); + } + })(); + + return () => { + cancelled = true; + }; + }, [question, previousResponseId]); + + return response; +} + +// Summary hook +function useSummary(visitedPages: any[]) { + const [summary, setSummary] = useState(''); + const [summaryResponseId, setSummaryResponseId] = useState(undefined); - if ('summary' in data && data.summary !== undefined) { - setSummary(data.summary); + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const stream = await streamAISearchSummary({ visitedPages }); + + for await (const rawData of stream) { + if (cancelled) break; + if (!rawData) continue; + + // Use type assertion + const data = rawData as any; + + if (data.responseId) { + setSummaryResponseId(String(data.responseId)); + } + + if (data.summary) { + setSummary(String(data.summary)); + } } + } catch (error) { + console.error('Error in summary stream:', error); } })(); @@ -56,51 +261,138 @@ export function SearchChat(props: { query: string }) { }; }, [visitedPages]); + return { summary, summaryResponseId }; +} + +// Main component +export function SearchChat(props: { + query: string; + chatInputRef?: React.RefObject; +}) { + const { query, chatInputRef } = props; + const visitedPages = useVisitedPages((state) => state.pages); + const [messages, setMessages] = useState([]); + const [followupQuestions, setFollowupQuestions] = useState([]); + const [conversationResponseId, setConversationResponseId] = useState(); + const [searchState, setSearchState] = useSearch(); + const latestMessageRef = useRef(null); + + const isExpanded = searchState?.mode === 'chat'; + + // Get summary of visited pages + const { summary, summaryResponseId } = useSummary(visitedPages); + + // Handle initial query + const initialResponse = useAIStream({ + question: query, + previousResponseId: summaryResponseId, + }); + + // Set up initial query effect useEffect(() => { - let cancelled = false; + if (!query) return; + + // Add initial assistant message + setMessages([ + { + role: 'assistant', + context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`, + fetching: true, + }, + ]); + + setFollowupQuestions([]); + setConversationResponseId(undefined); + }, [query]); - if (query) { + // Update message when initial response changes + useEffect(() => { + if (!query || !initialResponse) return; + + if (initialResponse.content !== undefined) { setMessages([ { role: 'assistant', context: `You asked ${isQuestion(query) ? '' : 'about'} "${query}"`, - fetching: true, + content: initialResponse.content, + fetching: initialResponse.fetching, }, ]); - setFollowupQuestions([]); + } - (async () => { + if (initialResponse.followupQuestions) { + setFollowupQuestions(initialResponse.followupQuestions); + } + + if (initialResponse.responseId) { + setConversationResponseId(initialResponse.responseId); + } + }, [initialResponse, query]); + + // Handle follow-up messages + const handleSendMessage = (message: string) => { + // Add user message + const newMessages: Message[] = [ + ...messages, + { role: 'user', content: message, fetching: false }, + { role: 'assistant', fetching: true }, + ]; + + setMessages(newMessages); + setFollowupQuestions([]); + if (!searchState?.manual) { + setSearchState((state) => (state ? { ...state, mode: 'chat' } : null)); + } + + // Get AI response + const cancelled = false; + + (async () => { + try { const stream = await streamAISearchAnswer({ - question: query, - previousResponseId: responseId ?? undefined, + question: message, + previousResponseId: conversationResponseId, }); - for await (const data of stream) { - if (cancelled) return; + for await (const rawData of stream) { + if (cancelled) break; + if (!rawData) continue; + + // Use type assertion + const data = rawData as any; - if ('responseId' in data && data.responseId !== undefined) { - setResponseId(data.responseId); + if (data.responseId) { + setConversationResponseId(String(data.responseId)); } - if ('answer' in data && data.answer !== undefined) { + if (data.answer !== undefined) { setMessages((prev) => [ ...prev.slice(0, -1), { role: 'assistant', content: data.answer, fetching: false }, ]); } - if ('followupQuestions' in data && data.followupQuestions !== undefined) { + if (data.followupQuestions && Array.isArray(data.followupQuestions)) { setFollowupQuestions(data.followupQuestions.filter(filterOutNullable)); } } - })(); + } catch (error) { + console.error('Error in follow-up stream:', error); + // Update the message to show an error state + setMessages((prev) => [ + ...prev.slice(0, -1), + { role: 'assistant', fetching: false }, + ]); + } + })(); + }; - return () => { - cancelled = true; - }; - } - }, [query, responseId]); + // Handle followup question click + const handleFollowupClick = (question: string) => { + handleSendMessage(question); + }; + // Auto-scroll to latest message useEffect(() => { if (latestMessageRef.current) { latestMessageRef.current.scrollIntoView({ @@ -111,9 +403,13 @@ export function SearchChat(props: { query: string }) { }, [messages]); return ( -
- {searchState?.mode === 'chat' ? ( -
+
+ {/* Toggle button for showing search results */} + {searchState?.mode === 'chat' && ( +
- ) : null} + )} + + {/* Main chat area */}
+ {/* Summary section */}
Summary of what you've read
- - {summary ? ( - summary - ) : ( -
- {[...Array(9)].map((_, index) => ( -
- ))} -
- )} + {summary ? summary : }
- {messages.map((message, index) => ( -
- {message.role === 'user' ? ( -
- {message.context ?? `You asked ${isQuestion(query) ? '' : 'about'}`} -
- ) : ( -
- {' '} - {message.context ?? 'AI Answer'} -
- )} - {message.fetching ? ( -
- {[...Array(9)].map((_, index) => ( -
- ))} -
- ) : ( -
- {message.content} -
- )} - - {index === messages.length - 1 && - followupQuestions && - followupQuestions.length > 0 && ( -
- {followupQuestions?.map((question) => ( -
- {question} -
- ))} -
+ {/* Messages */} + {messages.map((message, index) => { + const isLast = index === messages.length - 1; + return ( +
- ))} + > + + {isLast && followupQuestions && followupQuestions.length > 0 && ( + + )} +
+ ); + })}
- {query ? ( + {/* Input area */} + {query && (
-
-
- -
+
+
- ) : null} + )}
); } diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index 77d0044dc6..5bf3a409a1 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -27,14 +27,21 @@ export function SearchModal(props: SearchModalProps) { const searchAsk = useSearchAskState(); const [askState] = searchAsk; const router = useRouter(); + const chatInputRef = React.useRef(null); useHotkeys( 'mod+k', (e) => { e.preventDefault(); - setSearchState({ mode: 'both', query: '', global: false }); + if (state !== null) { + // If search is already open, focus the chat input + chatInputRef.current?.focus(); + } else { + // Otherwise open the search modal + setSearchState({ mode: 'both', query: '', global: false }); + } }, - [] + [state] ); // Add a global class on the body when the search modal is open @@ -122,6 +129,7 @@ export function SearchModal(props: SearchModalProps) { state={state} setSearchState={setSearchState} onClose={onClose} + chatInputRef={chatInputRef} />
@@ -136,9 +144,11 @@ function SearchModalBody( state: SearchState; setSearchState: UpdateSearchState; onClose: (to?: string) => void; + chatInputRef: React.RefObject; } ) { - const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose } = props; + const { spaceTitle, withAsk, isMultiVariants, state, setSearchState, onClose, chatInputRef } = + props; const language = useLanguage(); const resultsRef = React.useRef(null); @@ -162,6 +172,12 @@ function SearchModalBody( }, [onClose]); const onKeyDown = (event: React.KeyboardEvent) => { + // Handle second Cmd+K + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + event.preventDefault(); + chatInputRef.current?.focus(); + return; + } if (event.key === 'ArrowUp') { event.preventDefault(); resultsRef.current?.moveUp(); @@ -287,8 +303,8 @@ function SearchModalBody(
@@ -304,13 +320,15 @@ function SearchModalBody(
- +
diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index b16675bd03..03baa3e2a5 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -75,44 +75,25 @@ export const SearchResults = React.forwardRef(function SearchResults( const results: ResultType[] = React.useMemo(() => resultsState.results, [resultsState.results]); React.useEffect(() => { - console.log('Effect running with:', { - query, - resultsLength: results.length, - fetching: resultsState.fetching, - manual: searchState?.manual, - currentState: searchState, - }); - - // If manual is true, don't do any automatic mode changes - if (searchState?.manual) { - console.log('Skipping automatic mode changes because manual is true'); - return; - } - if (!query) { // Reset the cursor when there's no query setCursor(null); - } else if (results.length > 0) { - // Auto-focus the first result - console.log('Setting mode to both'); + } else if (!searchState?.manual && !resultsState.fetching && results.length === 0) { setSearchState((prev) => { const newState: SearchState | null = prev - ? { ...prev, mode: 'both' as const } + ? { ...prev, mode: 'chat' as const } : null; - console.log('New state will be:', newState); return newState; }); - setCursor(0); - } else if (results.length === 0 && !resultsState.fetching) { - // Only switch to chat mode if manual is false - console.log('Setting mode to chat'); + } else if (results.length > 0) { + // Auto-focus the first result setSearchState((prev) => { const newState: SearchState | null = prev - ? { ...prev, mode: 'chat' as const } + ? { ...prev, mode: 'both' as const } : null; - console.log('New state will be:', newState); return newState; }); + setCursor(0); } }, [results, query, setSearchState, resultsState.fetching, searchState?.manual]); @@ -258,7 +239,9 @@ export const SearchResults = React.forwardRef(function SearchResults( const noResults = (
- {t(language, 'search_no_results', query)} +
+ {t(language, 'search_no_results', query)} +
); diff --git a/packages/gitbook/src/components/Search/isQuestion.ts b/packages/gitbook/src/components/Search/isQuestion.ts index 15f714b590..d21c97c036 100644 --- a/packages/gitbook/src/components/Search/isQuestion.ts +++ b/packages/gitbook/src/components/Search/isQuestion.ts @@ -28,7 +28,7 @@ const questionWords = new Set([ * Return true if an input query looks like a question. */ export function isQuestion(query: string): boolean { - if (query.length > 25 || query.includes('?') || query.includes(' ')) { + if ((query.length > 25 && query.includes(' ')) || query.includes('?')) { return true; } diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index 55575053b4..822db47bab 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -539,7 +539,7 @@ export async function* streamAISearchAnswer({ const answer = value.answer; const followupQuestions = value.followupQuestions; - if (!answer) { + if (answer === undefined) { continue; } From d678a9162de86feade77f8f31f542f553ea204d9 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Fri, 23 May 2025 16:54:28 +0200 Subject: [PATCH 8/8] Fix typing & translations --- packages/gitbook/src/components/Search/SearchAskAnswer.tsx | 1 - packages/gitbook/src/components/Search/SearchButton.tsx | 2 +- packages/gitbook/src/components/Search/SearchResults.tsx | 6 +++--- packages/gitbook/src/intl/translations/de.ts | 1 + packages/gitbook/src/intl/translations/es.ts | 1 + packages/gitbook/src/intl/translations/fr.ts | 1 + packages/gitbook/src/intl/translations/ja.ts | 1 + packages/gitbook/src/intl/translations/nl.ts | 7 ++++--- packages/gitbook/src/intl/translations/no.ts | 1 + packages/gitbook/src/intl/translations/pt-br.ts | 1 + packages/gitbook/src/intl/translations/zh.ts | 1 + 11 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index 7e8f83bd25..81380d2dfc 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -186,7 +186,6 @@ function AnswerFollowupQuestions(props: { followupQuestions: string[] }) { )} {...getSearchLinkProps({ query: question, - ask: true, })} > { setSearchState({ - ask: false, + mode: 'both', global: false, query: '', }); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index 03baa3e2a5..5a31ca2627 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { t, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; -import { AnimatePresence } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { useTrackEvent } from '../Insights'; import { Loading } from '../primitives'; import { SearchPageResultItem } from './SearchPageResultItem'; @@ -252,7 +252,7 @@ export const SearchResults = React.forwardRef(function SearchResults( ) : query && results.length === 0 ? ( noResults ) : ( -
+ )} ); diff --git a/packages/gitbook/src/intl/translations/de.ts b/packages/gitbook/src/intl/translations/de.ts index b42da6c55f..2d4653466e 100644 --- a/packages/gitbook/src/intl/translations/de.ts +++ b/packages/gitbook/src/intl/translations/de.ts @@ -6,6 +6,7 @@ export const de = { switch_to_light_theme: 'Zum hellen Modus wechseln', switch_to_system_theme: 'Zum Systemmodus wechseln', search: 'Suche', + view: 'Anzeigen', search_or_ask: 'Fragen oder Suchen', search_input_placeholder: 'Inhalt durchsuchen', search_ask_input_placeholder: 'Inhalt durchsuchen oder eine Frage stellen', diff --git a/packages/gitbook/src/intl/translations/es.ts b/packages/gitbook/src/intl/translations/es.ts index 2d0d465652..0358bc17bb 100644 --- a/packages/gitbook/src/intl/translations/es.ts +++ b/packages/gitbook/src/intl/translations/es.ts @@ -8,6 +8,7 @@ export const es: TranslationLanguage = { switch_to_light_theme: 'Cambiar a tema claro', switch_to_system_theme: 'Cambiar a tema del sistema', search: 'Buscar', + view: 'Ver', search_or_ask: 'Preguntar o Buscar', search_input_placeholder: 'Buscar contenido', search_ask_input_placeholder: 'Buscar contenido o hacer una pregunta', diff --git a/packages/gitbook/src/intl/translations/fr.ts b/packages/gitbook/src/intl/translations/fr.ts index 71d340332b..8163888851 100644 --- a/packages/gitbook/src/intl/translations/fr.ts +++ b/packages/gitbook/src/intl/translations/fr.ts @@ -8,6 +8,7 @@ export const fr: TranslationLanguage = { switch_to_light_theme: 'Passer au thème clair', switch_to_system_theme: 'Passer au thème système', search: 'Rechercher', + view: 'Voir', search_or_ask: 'Demander ou rechercher', search_input_placeholder: 'Rechercher le contenu', search_ask_input_placeholder: 'Rechercher du contenu ou poser une question', diff --git a/packages/gitbook/src/intl/translations/ja.ts b/packages/gitbook/src/intl/translations/ja.ts index f3f5480689..030cccd16f 100644 --- a/packages/gitbook/src/intl/translations/ja.ts +++ b/packages/gitbook/src/intl/translations/ja.ts @@ -8,6 +8,7 @@ export const ja: TranslationLanguage = { switch_to_light_theme: 'ライトテーマに切り替え', switch_to_system_theme: 'システムのテーマに切り替え', search: '検索', + view: '表示', search_or_ask: '質問または検索', search_input_placeholder: 'コンテンツを検索', search_ask_input_placeholder: 'コンテンツを検索するか質問をする', diff --git a/packages/gitbook/src/intl/translations/nl.ts b/packages/gitbook/src/intl/translations/nl.ts index 6bfb45e9a6..57cc220088 100644 --- a/packages/gitbook/src/intl/translations/nl.ts +++ b/packages/gitbook/src/intl/translations/nl.ts @@ -8,6 +8,7 @@ export const nl: TranslationLanguage = { switch_to_light_theme: 'Schakel over naar lichte modus', switch_to_system_theme: 'Schakel over naar systeemmodus', search: 'Zoeken', + view: 'Bekijken', search_or_ask: 'Zoek of vraag', search_input_placeholder: 'Zoek inhoud', search_ask_input_placeholder: 'Zoek inhoud of stel een vraag', @@ -17,7 +18,7 @@ export const nl: TranslationLanguage = { search_ask: 'Vraag "${1}"', search_ask_description: 'Vind het antwoord met AI', search_ask_sources: 'Bronnen', - search_ask_sources_no_answer: 'Gerelateerde pagina’s', + search_ask_sources_no_answer: "Gerelateerde pagina's", search_ask_no_answer: 'Er kon geen antwoord op je vraag worden gevonden. Probeer je vraag anders te formuleren of wees specifieker.', search_ask_error: 'Er is iets misgegaan. Probeer het later opnieuw.', @@ -54,9 +55,9 @@ export const nl: TranslationLanguage = { pdf_print: 'Print of opslaan als PDF', pdf_page_of: '${1} van ${2}', pdf_mode_only_page: 'Alleen deze pagina', - pdf_mode_all: 'Alle pagina’s', + pdf_mode_all: "Alle pagina's", pdf_limit_reached: "Kon de PDF niet genereren voor ${1} pagina's, generatie gestopt bij ${2}.", - pdf_limit_reached_continue: 'Verleng met ${1} extra pagina’s.', + pdf_limit_reached_continue: "Verleng met ${1} extra pagina's.", more: 'Meer', link_tooltip_external_link: 'Externe link naar', link_tooltip_page_anchor: 'Spring naar sectie', diff --git a/packages/gitbook/src/intl/translations/no.ts b/packages/gitbook/src/intl/translations/no.ts index 6be6b413e3..b9c4aaa47f 100644 --- a/packages/gitbook/src/intl/translations/no.ts +++ b/packages/gitbook/src/intl/translations/no.ts @@ -8,6 +8,7 @@ export const no: TranslationLanguage = { switch_to_light_theme: 'Bytt til lyst tema', switch_to_system_theme: 'Bytt til systemtema', search: 'Søk', + view: 'Vis', search_or_ask: 'Spør eller søk', search_input_placeholder: 'Søk i innhold', search_ask_input_placeholder: 'Søk i innhold eller still et spørsmål', diff --git a/packages/gitbook/src/intl/translations/pt-br.ts b/packages/gitbook/src/intl/translations/pt-br.ts index 35a40cd45f..d33f0651d6 100644 --- a/packages/gitbook/src/intl/translations/pt-br.ts +++ b/packages/gitbook/src/intl/translations/pt-br.ts @@ -6,6 +6,7 @@ export const pt_br = { switch_to_light_theme: 'Mudar para modo claro', switch_to_system_theme: 'Mudar para configuração do sistema', search: 'Busca', + view: 'Ver', search_or_ask: 'Perguntar ou buscar', search_input_placeholder: 'Buscar conteúdo', search_ask_input_placeholder: 'Buscar conteúdo ou fazer uma pergunta', diff --git a/packages/gitbook/src/intl/translations/zh.ts b/packages/gitbook/src/intl/translations/zh.ts index 08efdddde6..31e283b3f9 100644 --- a/packages/gitbook/src/intl/translations/zh.ts +++ b/packages/gitbook/src/intl/translations/zh.ts @@ -8,6 +8,7 @@ export const zh: TranslationLanguage = { switch_to_light_theme: '切换到浅色主题', switch_to_system_theme: '切换到系统主题', search: '搜索', + view: '查看', search_or_ask: '询问或搜索', search_input_placeholder: '搜索内容', search_ask_input_placeholder: '搜索内容或提问',