diff --git a/.changeset/late-cherries-rule.md b/.changeset/late-cherries-rule.md new file mode 100644 index 0000000000..d546d94ba5 --- /dev/null +++ b/.changeset/late-cherries-rule.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Adds AI sidebar with recommendations based on browsing behaviour diff --git a/packages/gitbook-v2/src/lib/data/api.ts b/packages/gitbook-v2/src/lib/data/api.ts index 20e495656e..0af1e9e367 100644 --- a/packages/gitbook-v2/src/lib/data/api.ts +++ b/packages/gitbook-v2/src/lib/data/api.ts @@ -1394,6 +1394,7 @@ async function* streamAIResponse( input: params.input, output: params.output, model: params.model, + tools: params.tools, }); for await (const event of res) { diff --git a/packages/gitbook-v2/src/lib/data/types.ts b/packages/gitbook-v2/src/lib/data/types.ts index 178a0ba77d..a2b1519389 100644 --- a/packages/gitbook-v2/src/lib/data/types.ts +++ b/packages/gitbook-v2/src/lib/data/types.ts @@ -189,5 +189,6 @@ export interface GitBookDataFetcher { input: api.AIMessageInput[]; output: api.AIOutputFormat; model: api.AIModel; + tools?: api.AIToolCapabilities; }): AsyncGenerator<api.AIStreamResponse, void, unknown>; } diff --git a/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx new file mode 100644 index 0000000000..04e0e776a6 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx @@ -0,0 +1,138 @@ +'use client'; +import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import { AnimatePresence, motion } from 'framer-motion'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useVisitedPages } from '../Insights'; +import { usePageContext } from '../PageContext'; +import { Emoji } from '../primitives'; +import { type SuggestedPage, useAdaptiveContext } from './AdaptiveContext'; +import { streamNextPageSuggestions } from './server-actions/streamNextPageSuggestions'; + +export function AINextPageSuggestions() { + const { selectedJourney, open } = useAdaptiveContext(); + + const currentPage = usePageContext(); + const visitedPages = useVisitedPages((state) => state.pages); + + const [pages, setPages] = useState<SuggestedPage[]>(selectedJourney?.pages ?? []); + const [suggestedPages, setSuggestedPages] = useState<SuggestedPage[]>([]); + + useEffect(() => { + let canceled = false; + + if (selectedJourney?.pages && selectedJourney.pages.length > 0) { + setPages(selectedJourney.pages); + } else { + setPages(suggestedPages); + } + + if (suggestedPages.length === 0) { + (async () => { + const stream = await streamNextPageSuggestions({ + currentPage: { + id: currentPage.pageId, + title: currentPage.title, + }, + currentSpace: { + id: currentPage.spaceId, + }, + visitedPages: visitedPages, + }); + + for await (const page of stream) { + if (canceled) return; + + setPages((prev) => [...prev, page]); + setSuggestedPages((prev) => [...prev, page]); + } + })(); + } + + return () => { + canceled = true; + }; + }, [ + selectedJourney, + currentPage.pageId, + currentPage.spaceId, + currentPage.title, + visitedPages, + suggestedPages, + ]); + + return ( + open && ( + <div className="animate-fadeIn"> + <motion.div className="mb-2 flex flex-row items-start gap-3"> + <AnimatePresence mode="wait"> + {selectedJourney?.icon ? ( + <motion.div + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ delay: 0.2 }} + key={selectedJourney.icon} + > + <Icon + icon={selectedJourney.icon as IconName} + className="left-0 mt-2 size-6 shrink-0 text-tint-subtle" + /> + </motion.div> + ) : null} + </AnimatePresence> + <motion.div className={tcls('flex flex-col')} layout="position"> + <div className="font-semibold text-tint text-xs uppercase tracking-wide"> + Suggested pages + </div> + <AnimatePresence mode="wait"> + {selectedJourney?.label ? ( + <motion.h5 + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + key={selectedJourney.label} + className="font-semibold text-base" + > + {selectedJourney.label} + </motion.h5> + ) : null} + </AnimatePresence> + </motion.div> + </motion.div> + <div className="-mb-1.5 flex flex-col gap-1"> + {Object.assign(Array.from({ length: 5 }), pages).map( + (page: SuggestedPage | undefined, index) => + page ? ( + <Link + key={`${selectedJourney?.label}-${page.id}`} + className="-mx-2 flex animate-fadeIn gap-2 rounded px-2.5 py-1 transition-all hover:bg-tint-hover hover:text-tint-strong" + href={page.href} + style={{ animationDelay: `${0.2 + index * 0.05}s` }} + > + {page.icon ? ( + <Icon + icon={page.icon as IconName} + className="mt-0.5 size-4 shrink-0 text-tint-subtle" + /> + ) : null} + {page.emoji ? <Emoji code={page.emoji} /> : null} + {page.title} + </Link> + ) : ( + <div + key={index} + className="my-1 h-5 animate-pulse rounded bg-tint-hover" + style={{ + animationDelay: `${index * 0.2}s`, + width: `${((index * 17) % 50) + 50}%`, + }} + /> + ) + )} + </div> + </div> + ) + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx new file mode 100644 index 0000000000..a00d4721ea --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx @@ -0,0 +1,60 @@ +'use client'; +import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import { JOURNEY_COUNT, type Journey, useAdaptiveContext } from './AdaptiveContext'; + +export function AIPageJourneySuggestions() { + const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext(); + + return ( + open && ( + <div className="animate-fadeIn"> + <div className="mb-2 flex flex-row items-center gap-1 font-semibold text-tint text-xs uppercase tracking-wide"> + More to explore + </div> + <div className="grid grid-cols-2 gap-2"> + {Object.assign(Array.from({ length: JOURNEY_COUNT }), journeys).map( + (journey: Journey | undefined, index) => { + const isSelected = + journey?.label && journey.label === selectedJourney?.label; + const isLoading = !journey || journey?.label === undefined; + return ( + <button + type="button" + key={index} + disabled={journey?.label === undefined} + className={tcls( + 'flex flex-col items-center justify-center gap-2 rounded bg-tint px-2 py-4 text-center ring-1 ring-tint-subtle ring-inset transition-all', + isLoading + ? 'h-24 scale-90 animate-pulse' + : 'hover:bg-tint-hover hover:text-tint-strong hover:ring-tint active:scale-95', + isSelected && + 'bg-primary-active text-primary-strong ring-2 ring-primary hover:bg-primary-active hover:ring-primary' + )} + style={{ + animationDelay: `${index * 0.2}s`, + }} + onClick={() => + setSelectedJourney(isSelected ? undefined : journey) + } + > + {journey?.icon ? ( + <Icon + icon={journey.icon as IconName} + className="size-4 animate-fadeIn text-tint-subtle [animation-delay:300ms]" + /> + ) : null} + {journey?.label ? ( + <span className="animate-fadeIn leading-tight [animation-delay:400ms]"> + {journey.label} + </span> + ) : null} + </button> + ); + } + )} + </div> + </div> + ) + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx new file mode 100644 index 0000000000..002818937c --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useVisitedPages } from '../Insights'; +import { usePageContext } from '../PageContext'; +import { streamPageJourneySuggestions } from './server-actions'; + +export type SuggestedPage = { + id: string; + title: string; + href: string; + icon?: string; + emoji?: string; +}; + +export type Journey = { + label: string; + icon?: string; + pages?: Array<SuggestedPage>; +}; + +type AdaptiveContextType = { + journeys: Journey[]; + selectedJourney: Journey | undefined; + setSelectedJourney: (journey: Journey | undefined) => void; + loading: boolean; + open: boolean; + setOpen: (open: boolean) => void; +}; + +export const AdaptiveContext = React.createContext<AdaptiveContextType | null>(null); + +export const JOURNEY_COUNT = 4; + +/** + * Client side context provider to pass information about the current page. + */ +export function JourneyContextProvider({ + children, + spaces, +}: { children: React.ReactNode; spaces: { id: string; title: string }[] }) { + const [journeys, setJourneys] = React.useState<Journey[]>([]); + const [selectedJourney, setSelectedJourney] = React.useState<Journey | undefined>(undefined); + const [loading, setLoading] = React.useState(true); + const [open, setOpen] = React.useState(true); + + const currentPage = usePageContext(); + const visitedPages = useVisitedPages((state) => state.pages); + + useEffect(() => { + let canceled = false; + + setJourneys([]); + + (async () => { + const stream = await streamPageJourneySuggestions({ + count: JOURNEY_COUNT, + currentPage: { + id: currentPage.pageId, + title: currentPage.title, + }, + currentSpace: { + id: currentPage.spaceId, + }, + allSpaces: spaces, + visitedPages, + }); + + for await (const journey of stream) { + if (canceled) return; + + setJourneys((prev) => [...prev, journey]); + } + + setLoading(false); + })(); + + return () => { + canceled = true; + }; + }, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]); + + return ( + <AdaptiveContext.Provider + value={{ journeys, selectedJourney, setSelectedJourney, loading, open, setOpen }} + > + {children} + </AdaptiveContext.Provider> + ); +} + +/** + * Hook to use the adaptive context. + */ +export function useAdaptiveContext() { + const context = React.useContext(AdaptiveContext); + if (!context) { + throw new Error('useAdaptiveContext must be used within a AdaptiveContextProvider'); + } + return context; +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx new file mode 100644 index 0000000000..c56134c8a8 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { tcls } from '@/lib/tailwind'; +import { AINextPageSuggestions } from './AINextPageSuggestions'; +import { AIPageJourneySuggestions } from './AIPageJourneySuggestions'; +import { useAdaptiveContext } from './AdaptiveContext'; +import { AdaptivePaneHeader } from './AdaptivePaneHeader'; +export function AdaptivePane() { + const { open } = useAdaptiveContext(); + + return ( + <div + className={tcls( + 'flex flex-col gap-4 rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300', + open ? 'w-72 px-4 py-4' : 'w-56 px-4 py-3' + )} + > + <AdaptivePaneHeader /> + <AIPageJourneySuggestions /> + <AINextPageSuggestions /> + </div> + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx new file mode 100644 index 0000000000..0141b13165 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { tcls } from '@/lib/tailwind'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Button, Loading } from '../primitives'; +import { useAdaptiveContext } from './AdaptiveContext'; + +export function AdaptivePaneHeader() { + const { loading, open, setOpen } = useAdaptiveContext(); + + return ( + <div className="flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500"> + <div className="flex grow flex-col"> + <h4 className="flex items-center gap-1.5 font-semibold "> + <Loading className="size-4 text-tint-subtle" busy={loading} /> + For you + </h4> + <AnimatePresence initial={false} mode="wait"> + <motion.h5 + key={loading ? 'loading' : 'loaded'} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + className="text-tint-subtle text-xs" + > + {loading ? 'Basing on your context...' : 'Based on your context'} + </motion.h5> + </AnimatePresence> + </div> + <Button + variant="blank" + className={tcls('px-2 *:transition-transform', !open && '*:-rotate-45')} + iconOnly + label="Close" + icon="close" + onClick={() => setOpen(!open)} + /> + </div> + ); +} diff --git a/packages/gitbook/src/components/Adaptive/index.ts b/packages/gitbook/src/components/Adaptive/index.ts index 2d93029d7e..cf7f351a2d 100644 --- a/packages/gitbook/src/components/Adaptive/index.ts +++ b/packages/gitbook/src/components/Adaptive/index.ts @@ -1 +1,3 @@ export * from './AIPageLinkSummary'; +export * from './AdaptiveContext'; +export * from './AdaptivePane'; diff --git a/packages/gitbook/src/components/Adaptive/server-actions/api.ts b/packages/gitbook/src/components/Adaptive/server-actions/api.ts index a1396987d7..fdc642c158 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'; @@ -47,11 +52,13 @@ export async function streamGenerateObject<T>( schema, messages, model = AIModel.Fast, + tools = {}, }: { schema: z.ZodSchema<T>; messages: AIMessageInput[]; model?: AIModel; previousResponseId?: string; + tools?: AIToolCapabilities; } ) { const rawStream = context.dataFetcher.streamAIResponse({ @@ -62,12 +69,13 @@ export async function streamGenerateObject<T>( type: 'object', schema: zodToJsonSchema(schema), }, + tools, model, }); let json = ''; return parseResponse<DeepPartial<T>>(rawStream, (event) => { - if (event.type === 'response_object') { + if (event.type === 'response_object' && event.jsonChunk) { json += event.jsonChunk; const parsed = partialJson.parse(json, partialJson.ALL); diff --git a/packages/gitbook/src/components/Adaptive/server-actions/index.ts b/packages/gitbook/src/components/Adaptive/server-actions/index.ts index 664e869e23..81f5686fc1 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/index.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/index.ts @@ -1 +1,2 @@ export * from './streamLinkPageSummary'; +export * from './streamPageJourneySuggestions'; diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts new file mode 100644 index 0000000000..8cae6ae414 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts @@ -0,0 +1,128 @@ +'use server'; +import { resolvePageId } from '@/lib/pages'; +import { getV1BaseContext } from '@/lib/v1'; +import { isV2 } from '@/lib/v2'; +import { AIMessageRole } from '@gitbook/api'; +import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; +import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions'; +import { z } from 'zod'; +import { streamGenerateObject } from './api'; + +/** + * Get a list of pages to read next + */ +export async function* streamNextPageSuggestions({ + currentPage, + currentSpace, + visitedPages, +}: { + currentPage: { + id: string; + title: string; + }; + currentSpace: { + id: string; + // title: string; + }; + visitedPages?: Array<{ spaceId: string; pageId: string }>; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const [{ stream }, context] = await Promise.all([ + streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + pages: z + .array(z.string().describe('The IDs of the page to read next.')) + .min(5) + .max(5), + }), + tools: { + getPages: true, + getPageContent: true, + }, + messages: [ + { + role: AIMessageRole.Developer, + content: + "You are a knowledge navigator. Given the user's visited pages and the documentation's table of contents, suggest a list of pages to read next.", + }, + { + role: AIMessageRole.Developer, + content: `The user is in space (ID ${currentSpace.id})`, + }, + // { + // role: AIMessageRole.Developer, + // content: `Other spaces in the documentation are: ${allSpaces + // .map( + // (space) => ` + // - "${space.title}" (ID ${space.id})` + // ) + // .join('\n')} + + // Feel free to create journeys across spaces.`, + // }, + { + role: AIMessageRole.Developer, + content: `The current page is: "${currentPage.title}" (ID ${currentPage.id}). You can use the getPageContent tool to get the content of any relevant links to include in the journey. Only follow links to pages.`, + attachments: [ + { + type: 'page' as const, + spaceId: currentSpace.id, + pageId: currentPage.id, + }, + ], + }, + ...(visitedPages && visitedPages.length > 0 + ? [ + { + role: AIMessageRole.Developer, + content: `The user's visited pages are: ${visitedPages.map((page) => page.pageId).join(', ')}. The content of the last 5 pages are included below.`, + attachments: visitedPages.slice(0, 5).map((page) => ({ + type: 'page' as const, + spaceId: page.spaceId, + pageId: page.pageId, + })), + }, + ] + : []), + ], + } + ), + fetchServerActionSiteContext(baseContext), + ]); + + const emitted = new Set<string>(); + for await (const value of stream) { + const pages = value.pages; + + if (!pages) continue; + + for (const pageId of pages) { + if (!pageId) continue; + if (emitted.has(pageId)) continue; + + emitted.add(pageId); + + const resolvedPage = resolvePageId(context.pages, pageId); + if (!resolvedPage) continue; + + yield { + id: resolvedPage.page.id, + title: resolvedPage.page.title, + icon: resolvedPage.page.icon, + emoji: resolvedPage.page.emoji, + href: context.linker.toPathForPage({ + pages: context.pages, + page: resolvedPage.page, + }), + }; + } + } +} diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts new file mode 100644 index 0000000000..08649217ca --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -0,0 +1,190 @@ +'use server'; +import { type AncestorRevisionPage, resolvePageId } from '@/lib/pages'; +import { getV1BaseContext } from '@/lib/v1'; +import { isV2 } from '@/lib/v2'; +import { AIMessageRole, type RevisionPageDocument } from '@gitbook/api'; +import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; +import { fetchServerActionSiteContext, 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* streamPageJourneySuggestions({ + currentPage, + currentSpace, + allSpaces, + visitedPages, + count, +}: { + currentPage: { + id: string; + title: string; + }; + currentSpace: { + id: string; + // title: string; + }; + allSpaces: { + id: string; + title: string; + }[]; + visitedPages?: Array<{ spaceId: string; pageId: string }>; + count: number; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const [{ stream }, context] = await Promise.all([ + streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + journeys: z + .array( + z.object({ + label: z.string().describe('The label of the journey.'), + icon: z + .string() + .describe( + 'The icon of the journey. Use an icon from FontAwesome, stripping the `fa-`. Examples: rocket-launch, tennis-ball, cat' + ), + pages: z + .array( + z.object({ + id: z.string(), + }) + ) + .describe( + 'A list of pages in the journey, excluding the current page. Try to avoid duplicate content that is very similar.' + ) + .min(5) + .max(10), + }) + ) + .describe('The possible journeys to take through the documentation.') + .min(count) + .max(count), + }), + tools: { + getPages: true, + getPageContent: true, + }, + messages: [ + { + role: AIMessageRole.Developer, + content: + "You are a knowledge navigator. Given the user's visited pages and the documentation's table of contents, suggest a named journey through the documentation. A journey is a list of pages that are related to each other. A journey's label starts with a verb and has a clear subject. Use sentence case (so only capitalize the first letter of the first word). Be concise and use short words to fit in the label. For example, use 'docs' instead of 'documentation'. Try to pick out specific journeys, not too generic.", + }, + { + role: AIMessageRole.Developer, + content: `The user is in space "${allSpaces.find((space) => space.id === currentSpace.id)?.title}" (ID ${currentSpace.id})`, + }, + { + role: AIMessageRole.Developer, + content: `Other spaces in the documentation are: ${allSpaces + .map( + (space) => ` +- "${space.title}" (ID ${space.id})` + ) + .join('\n')} + +Feel free to create journeys across spaces.`, + }, + { + role: AIMessageRole.Developer, + content: `The current page is: "${currentPage.title}" (ID ${currentPage.id}). You can use the getPageContent tool to get the content of any relevant links to include in the journey. Only follow links to pages.`, + attachments: [ + { + type: 'page' as const, + spaceId: currentSpace.id, + pageId: currentPage.id, + }, + ], + }, + ...(visitedPages && visitedPages.length > 0 + ? [ + { + role: AIMessageRole.Developer, + content: `The user's visited pages are: ${visitedPages.map((page) => page.pageId).join(', ')}. The content of the last 5 pages are included below.`, + attachments: visitedPages.slice(0, 5).map((page) => ({ + type: 'page' as const, + spaceId: page.spaceId, + pageId: page.pageId, + })), + }, + ] + : []), + ], + } + ), + fetchServerActionSiteContext(baseContext), + ]); + + const emitted: { label: string; pageIds: string[] }[] = []; + const allEmittedPageIds = new Set<string>(); + + for await (const value of stream) { + const journeys = value.journeys; + + if (!journeys) continue; + + for (const journey of journeys) { + if (!journey?.label) continue; + if (!journey?.pages || journey.pages?.length === 0) continue; + if (emitted.find((item) => item.label === journey.label)) continue; + + const pageIds: string[] = []; + const resolvedPages: { + page: RevisionPageDocument; + ancestors: AncestorRevisionPage[]; + }[] = []; + for (const page of journey.pages) { + if (!page) continue; + if (!page.id) continue; + if (pageIds.includes(page.id)) continue; + + pageIds.push(page.id); + + const resolvedPage = resolvePageId(context.pages, page.id); + if (!resolvedPage) continue; + + resolvedPages.push(resolvedPage); + } + + emitted.push({ + label: journey.label, + pageIds: pageIds, + }); + + // Deduplicate pages before yielding + const uniquePages = resolvedPages.filter((page) => { + if (allEmittedPageIds.has(page.page.id)) { + return false; + } + allEmittedPageIds.add(page.page.id); + return true; + }); + + yield { + label: journey.label, + icon: journey.icon, + pages: uniquePages.map((page) => ({ + id: page.page.id, + title: page.page.title, + icon: page.page.icon, + emoji: page.page.emoji, + href: context.linker.toPathForPage({ + pages: context.pages, + page: page.page, + }), + })), + }; + } + } +} diff --git a/packages/gitbook/src/components/PageAside/PageActions.tsx b/packages/gitbook/src/components/PageAside/PageActions.tsx new file mode 100644 index 0000000000..31cf9f7d95 --- /dev/null +++ b/packages/gitbook/src/components/PageAside/PageActions.tsx @@ -0,0 +1,106 @@ +import { getSpaceLanguage, t } from '@/intl/server'; +import { tcls } from '@/lib/tailwind'; +import type { RevisionPageDocument, Space } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; +import type { GitBookSiteContext } from '@v2/lib/context'; +import React from 'react'; +import urlJoin from 'url-join'; +import { getPDFURLSearchParams } from '../PDF'; +import { PageFeedbackForm } from '../PageFeedback'; + +export function PageActions(props: { + page: RevisionPageDocument; + context: GitBookSiteContext; + withPageFeedback: boolean; +}) { + const { page, withPageFeedback, context } = props; + const { customization, space } = context; + const language = getSpaceLanguage(customization); + + const pdfHref = context.linker.toPathInSpace( + `~gitbook/pdf?${getPDFURLSearchParams({ + page: page.id, + only: true, + limit: 100, + }).toString()}` + ); + + return ( + <div + className={tcls( + 'flex', + 'flex-col', + 'gap-2', + 'sidebar-list-default:px-3', + 'page-api-block:xl:max-2xl:px-3', + 'empty:hidden' + )} + > + {withPageFeedback ? ( + <React.Suspense fallback={null}> + <PageFeedbackForm pageId={page.id} /> + </React.Suspense> + ) : null} + {customization.git.showEditLink && space.gitSync?.url && page.git ? ( + <div> + <a + href={urlJoin(space.gitSync.url, page.git.path)} + className={tcls( + 'flex', + 'flex-row', + 'items-center', + 'text-sm', + 'hover:text-tint-strong', + 'links-accent:hover:underline', + 'links-accent:hover:underline-offset-4', + 'links-accent:hover:decoration-[3px]', + 'links-accent:hover:decoration-primary-subtle' + )} + > + <Icon + icon={ + space.gitSync.installationProvider === 'gitlab' + ? 'gitlab' + : 'github' + } + className={tcls('size-4', 'mr-1.5')} + /> + {t(language, 'edit_on_git', getGitSyncName(space))} + </a> + </div> + ) : null} + {customization.pdf.enabled ? ( + <div> + <a + href={pdfHref} + className={tcls( + 'flex', + 'flex-row', + 'items-center', + 'text-sm', + 'hover:text-tint-strong', + 'links-accent:hover:underline', + 'links-accent:hover:underline-offset-4', + 'links-accent:hover:decoration-[3px]', + 'links-accent:hover:decoration-primary-subtle' + )} + > + <Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} /> + {t(language, 'pdf_download')} + </a> + </div> + ) : null} + </div> + ); +} + +function getGitSyncName(space: Space): string { + if (space.gitSync?.installationProvider === 'github') { + return 'GitHub'; + } + if (space.gitSync?.installationProvider === 'gitlab') { + return 'GitLab'; + } + + return 'Git'; +} diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index a51410cbc0..99cb7a2a25 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -3,22 +3,17 @@ import { type RevisionPageDocument, SiteAdsStatus, SiteInsightsAdPlacement, - type Space, } from '@gitbook/api'; -import { Icon } from '@gitbook/icons'; import type { GitBookSiteContext } from '@v2/lib/context'; import React from 'react'; -import urlJoin from 'url-join'; -import { getSpaceLanguage, t } from '@/intl/server'; -import { getDocumentSections } from '@/lib/document-sections'; import { tcls } from '@/lib/tailwind'; +import { AdaptivePane } from '../Adaptive/AdaptivePane'; import { Ad } from '../Ads'; -import { getPDFURLSearchParams } from '../PDF'; -import { PageFeedbackForm } from '../PageFeedback'; import { ThemeToggler } from '../ThemeToggler'; -import { ScrollSectionsList } from './ScrollSectionsList'; +import { PageActions } from './PageActions'; +import { PageOutline } from './PageOutline'; /** * Aside listing the headings in the document. @@ -33,33 +28,26 @@ export function PageAside(props: { }) { const { page, document, withPageFeedback, context } = props; const { customization, site, space } = context; - const language = getSpaceLanguage(customization); - const pdfHref = context.linker.toPathInSpace( - `~gitbook/pdf?${getPDFURLSearchParams({ - page: page.id, - only: true, - limit: 100, - }).toString()}` - ); + const useAdaptivePane = true; + return ( <aside className={tcls( 'group/aside', 'hidden', 'xl:flex', - // 'page-no-toc:lg:flex', 'flex-col', 'basis-56', - // 'page-no-toc:basis-40', - // 'page-no-toc:xl:basis-56', 'grow-0', 'shrink-0', 'break-anywhere', // To prevent long words in headings from breaking the layout 'text-tint', 'contrast-more:text-tint-strong', + 'text-sm', 'sticky', + // Without header 'lg:top-0', 'lg:max-h-screen', @@ -91,141 +79,19 @@ export function PageAside(props: { 'page-api-block:p-2' )} > - {page.layout.outline ? ( - <> - <div - className={tcls( - 'hidden', - 'page-api-block:xl:max-2xl:flex', - 'text-xs', - 'tracking-wide', - 'font-semibold', - 'uppercase', - - 'flex-row', - 'items-center', - 'gap-2' - )} - > - <Icon icon="block-quote" className={tcls('size-3')} /> - {t(language, 'on_this_page')} - <Icon - icon="chevron-down" - className={tcls( - 'size-3', - 'opacity-6', - 'ml-auto', - 'page-api-block:xl:max-2xl:group-hover/aside:hidden' - )} + <div className="lg:top:0 sticky flex grow flex-col gap-6 overflow-y-auto overflow-x-visible border-none py-8 *:border-tint-subtle site-header-sections:lg:top-[6.75rem] site-header:lg:top-16 [&>*:not(:first-child)]:border-t [&>*:not(:first-child)]:pt-6"> + {useAdaptivePane ? <AdaptivePane /> : null} + {page.layout.outline ? ( + <> + <PageOutline document={document} context={context} /> + <PageActions + page={page} + context={context} + withPageFeedback={withPageFeedback} /> - </div> - <div - className={tcls( - 'overflow-y-auto', - 'overflow-x-visible', - - 'flex', - 'flex-col', - 'shrink', - 'pb-12', - - 'sticky', - 'lg:top:0', - 'site-header:lg:top-16', - 'site-header-sections:lg:top-[6.75rem]', - - 'gap-6', - 'pt-8', - - 'page-api-block:xl:max-2xl:py-0', - // Hide it for api page, until hovered - 'page-api-block:xl:max-2xl:hidden', - 'page-api-block:xl:max-2xl:group-hover/aside:flex' - )} - > - {document ? ( - <React.Suspense fallback={null}> - <PageAsideSections document={document} context={context} /> - </React.Suspense> - ) : null} - <div - className={tcls( - 'flex', - 'flex-col', - 'gap-3', - 'sidebar-list-default:px-3', - 'border-t', - 'first:border-none', - 'border-tint-subtle', - 'py-4', - 'first:pt-0', - 'page-api-block:xl:max-2xl:px-3', - 'empty:hidden' - )} - > - {withPageFeedback ? ( - <React.Suspense fallback={null}> - <PageFeedbackForm pageId={page.id} className={tcls('mt-2')} /> - </React.Suspense> - ) : null} - {customization.git.showEditLink && space.gitSync?.url && page.git ? ( - <div> - <a - href={urlJoin(space.gitSync.url, page.git.path)} - className={tcls( - 'flex', - 'flex-row', - 'items-center', - 'text-sm', - 'hover:text-tint-strong', - 'links-accent:hover:underline', - 'links-accent:hover:underline-offset-4', - 'links-accent:hover:decoration-[3px]', - 'links-accent:hover:decoration-primary-subtle', - 'py-2' - )} - > - <Icon - icon={ - space.gitSync.installationProvider === 'gitlab' - ? 'gitlab' - : 'github' - } - className={tcls('size-4', 'mr-1.5')} - /> - {t(language, 'edit_on_git', getGitSyncName(space))} - </a> - </div> - ) : null} - {customization.pdf.enabled ? ( - <div> - <a - href={pdfHref} - className={tcls( - 'flex', - 'flex-row', - 'items-center', - 'text-sm', - 'hover:text-tint-strong', - 'links-accent:hover:underline', - 'links-accent:hover:underline-offset-4', - 'links-accent:hover:decoration-[3px]', - 'links-accent:hover:decoration-primary-subtle', - 'py-2' - )} - > - <Icon - icon="file-pdf" - className={tcls('size-4', 'mr-1.5')} - /> - {t(language, 'pdf_download')} - </a> - </div> - ) : null} - </div> - </div> - </> - ) : null} + </> + ) : null} + </div> <div className={tcls( 'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle', @@ -254,22 +120,3 @@ export function PageAside(props: { </aside> ); } - -async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) { - const { document, context } = props; - - const sections = await getDocumentSections(context, document); - - return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null; -} - -function getGitSyncName(space: Space): string { - if (space.gitSync?.installationProvider === 'github') { - return 'GitHub'; - } - if (space.gitSync?.installationProvider === 'gitlab') { - return 'GitLab'; - } - - return 'Git'; -} diff --git a/packages/gitbook/src/components/PageAside/PageOutline.tsx b/packages/gitbook/src/components/PageAside/PageOutline.tsx new file mode 100644 index 0000000000..84c40a25b1 --- /dev/null +++ b/packages/gitbook/src/components/PageAside/PageOutline.tsx @@ -0,0 +1,45 @@ +import { getSpaceLanguage, t } from '@/intl/server'; +import { getDocumentSections } from '@/lib/document-sections'; +import { tcls } from '@/lib/tailwind'; +import type { JSONDocument } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; +import type { GitBookSiteContext } from '@v2/lib/context'; +import React from 'react'; +import { ScrollSectionsList } from './ScrollSectionsList'; + +export async function PageOutline(props: { + document: JSONDocument | null; + context: GitBookSiteContext; +}) { + const { document, context } = props; + const { customization } = context; + const language = getSpaceLanguage(customization); + + if (!document) return; + + const sections = await getDocumentSections(context, document); + + return document && sections.length > 1 ? ( + <div> + <div className="mb-1 flex flex-row items-center gap-2 font-semibold text-xs uppercase tracking-wide"> + <Icon icon="block-quote" className={tcls('size-3')} /> + {t(language, 'on_this_page')} + </div> + <div + className={tcls( + 'flex', + 'flex-col' + + // 'page-api-block:xl:max-2xl:py-0', + // // Hide it for api page, until hovered + // 'page-api-block:xl:max-2xl:hidden', + // 'page-api-block:xl:max-2xl:group-hover/aside:flex' + )} + > + <React.Suspense fallback={null}> + <ScrollSectionsList sections={sections} /> + </React.Suspense> + </div> + </div> + ) : null; +} diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index fe6934a529..83a9d947d2 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -1,4 +1,8 @@ -import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api'; +import { + CustomizationHeaderPreset, + CustomizationThemeMode, + type SiteStructure, +} from '@gitbook/api'; import type { GitBookSiteContext } from '@v2/lib/context'; import { getPageDocument } from '@v2/lib/data'; import type { Metadata, Viewport } from 'next'; @@ -11,6 +15,7 @@ import { getPagePath } from '@/lib/pages'; import { isPageIndexable, isSiteIndexable } from '@/lib/seo'; import { getResizedImageURL } from '@v2/lib/images'; +import { JourneyContextProvider } from '../Adaptive/AdaptiveContext'; import { PageContextProvider } from '../PageContext'; import { PageClientLayout } from './PageClientLayout'; import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch'; @@ -66,30 +71,32 @@ export async function SitePage(props: SitePageProps) { return ( <PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}> - {withFullPageCover && page.cover ? ( - <PageCover as="full" page={page} cover={page.cover} context={context} /> - ) : null} - {/* We use a flex row reverse to render the aside first because the page is streamed. */} - <div className="flex grow flex-row-reverse justify-end"> - <PageAside - page={page} - document={document} - withHeaderOffset={headerOffset} - withFullPageCover={withFullPageCover} - withPageFeedback={withPageFeedback} - context={context} - /> - <PageBody - context={context} - page={page} - ancestors={ancestors} - document={document} - withPageFeedback={withPageFeedback} - /> - </div> - <React.Suspense fallback={null}> - <PageClientLayout withSections={withSections} /> - </React.Suspense> + <JourneyContextProvider spaces={getSpaces(context.structure)}> + {withFullPageCover && page.cover ? ( + <PageCover as="full" page={page} cover={page.cover} context={context} /> + ) : null} + {/* We use a flex row reverse to render the aside first because the page is streamed. */} + <div className="flex grow flex-row-reverse justify-end"> + <PageAside + page={page} + document={document} + withHeaderOffset={headerOffset} + withFullPageCover={withFullPageCover} + withPageFeedback={withPageFeedback} + context={context} + /> + <PageBody + context={context} + page={page} + ancestors={ancestors} + document={document} + withPageFeedback={withPageFeedback} + /> + </div> + <React.Suspense fallback={null}> + <PageClientLayout withSections={withSections} /> + </React.Suspense> + </JourneyContextProvider> </PageContextProvider> ); } @@ -163,3 +170,23 @@ async function getPageDataWithFallback(args: { pageTarget, }; } + +function getSpaces(structure: SiteStructure) { + if (structure.type === 'siteSpaces') { + return structure.structure.map((siteSpace) => ({ + id: siteSpace.space.id, + title: siteSpace.space.title, + })); + } + + const sections = structure.structure.flatMap((item) => + item.object === 'site-section-group' ? item.sections : item + ); + + return sections.flatMap((section) => + section.siteSpaces.map((siteSpace) => ({ + id: siteSpace.space.id, + title: siteSpace.space.title, + })) + ); +} diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 2e5b7c359f..d0596cca98 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -296,14 +296,14 @@ const config: Config = { }, animation: { present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both', - scaleIn: 'scaleIn 200ms ease', - scaleOut: 'scaleOut 200ms ease', - fadeIn: 'fadeIn 200ms ease forwards', - fadeOut: 'fadeOut 200ms ease forwards', - enterFromLeft: 'enterFromLeft 250ms ease', - enterFromRight: 'enterFromRight 250ms ease', - exitToLeft: 'exitToLeft 250ms ease', - exitToRight: 'exitToRight 250ms ease', + scaleIn: 'scaleIn 200ms ease both', + scaleOut: 'scaleOut 200ms ease both', + fadeIn: 'fadeIn 200ms ease both', + fadeOut: 'fadeOut 200ms ease both', + enterFromLeft: 'enterFromLeft 250ms ease both', + enterFromRight: 'enterFromRight 250ms ease both', + exitToLeft: 'exitToLeft 250ms ease both', + exitToRight: 'exitToRight 250ms ease both', }, keyframes: { pulseAlt: {