diff --git a/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx new file mode 100644 index 000000000..49f2dfca8 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx @@ -0,0 +1,128 @@ +'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( + selectedJourney?.pages ?? Array.from({ length: 5 }) + ); + + useEffect(() => { + let canceled = false; + + if (selectedJourney?.pages && selectedJourney.pages.length > 0) { + setPages(selectedJourney.pages); + } + + (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) => { + const newPages = [...prev]; + const emptyIndex = newPages.findIndex((j) => !j?.id); + if (emptyIndex >= 0) { + newPages[emptyIndex] = page; + } + return newPages; + }); + } + })(); + + return () => { + canceled = true; + }; + }, [selectedJourney, currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages]); + + return ( + + {open && ( + +
+ {selectedJourney?.icon ? ( + + ) : null} +
+
+ Suggested pages +
+ {selectedJourney?.label ? ( +
+ {selectedJourney.label} +
+ ) : null} +
+
+
+ {pages.map((page, index) => + page?.id ? ( + + {page.icon ? ( + + ) : null} + {page.emoji ? : null} + {page.title} + + ) : ( +
+ ) + )} +
+ + )} + + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx index 57516675a..33d0a6299 100644 --- a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx +++ b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx @@ -1,146 +1,66 @@ 'use client'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; -import Link from 'next/link'; -import { useEffect } from 'react'; -import { useState } from 'react'; -import { useVisitedPages } from '../Insights'; -import { usePageContext } from '../PageContext'; -import { streamPageJourneySuggestions } from './server-actions'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useAdaptiveContext } from './AdaptiveContext'; -const JOURNEY_COUNT = 4; - -export function AIPageJourneySuggestions(props: { spaces: { id: string; title: string }[] }) { - const { spaces } = props; - - const currentPage = usePageContext(); - - // const language = useLanguage(); - const visitedPages = useVisitedPages((state) => state.pages); - const [journeys, setJourneys] = useState< - Array<{ - label: string; - icon?: string; - pages?: Array<{ - id: string; - title: string; - href: string; - icon?: string; - emoji?: string; - }>; - }> - >(Array.from({ length: JOURNEY_COUNT })); - const [selected, setSelected] = useState< - | { - label: string; - icon?: string; - pages?: Array<{ - id: string; - title: string; - href: string; - icon?: string; - emoji?: string; - }>; - } - | undefined - >(); - - useEffect(() => { - let canceled = false; - - (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; - - // Find the first empty slot in the journeys array - setJourneys((prev) => { - const newJourneys = [...prev]; - const emptyIndex = newJourneys.findIndex((j) => !j?.label); - if (emptyIndex >= 0) { - newJourneys[emptyIndex] = journey; - } - return newJourneys; - }); - } - })(); - - return () => { - canceled = true; - }; - }, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]); +export function AIPageJourneySuggestions() { + const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext(); return ( -
-
- {journeys.map((journey, i) => ( - - ))} -
- {selected && ( -
-

- {selected.icon ? ( - - ) : null} - {selected.label} -

-
    - {selected.pages?.map((page, index) => ( -
  1. - - - {page.title} - -
  2. - ))} -
-
+ + {open && ( + +
+ More to explore +
+
+ {journeys.map((journey, i) => { + const isSelected = + journey?.label && journey.label === selectedJourney?.label; + const isLoading = journey?.label === undefined; + return ( + + ); + })} +
+
)} -
+ ); } diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx new file mode 100644 index 000000000..9d1465598 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx @@ -0,0 +1,108 @@ +'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; +}; + +type Journey = { + label: string; + icon?: string; + pages?: Array; +}; + +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(null); + +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( + Array.from({ length: JOURNEY_COUNT }) + ); + const [selectedJourney, setSelectedJourney] = React.useState(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; + + (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) => { + const newJourneys = [...prev]; + const emptyIndex = newJourneys.findIndex((j) => !j?.label); + if (emptyIndex >= 0) { + newJourneys[emptyIndex] = journey; + } + return newJourneys; + }); + } + + setLoading(false); + })(); + + return () => { + canceled = true; + }; + }, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]); + + return ( + + {children} + + ); +} + +/** + * 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 index 174169e35..08c927a0c 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -1,40 +1,23 @@ -import type { SiteStructure } from '@gitbook/api'; -import { Icon } from '@gitbook/icons'; -import type { GitBookSiteContext } from '@v2/lib/context'; -import { AIPageJourneySuggestions } from './AIPageJourneySuggestions'; +'use client'; -export function AdaptivePane(props: { context: GitBookSiteContext }) { - const { context } = props; +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 ( - <> -
-
- - More to explore -
- -
- - ); -} - -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/src/components/Adaptive/AdaptivePaneHeader.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx new file mode 100644 index 000000000..02ccdf907 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx @@ -0,0 +1,45 @@ +'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 ( +
+
+

+ + For you +

+ + + {loading ? 'Basing on your context...' : 'Based on your context'} + + +
+
+ ); +} diff --git a/packages/gitbook/src/components/Adaptive/index.ts b/packages/gitbook/src/components/Adaptive/index.ts index 2d93029d7..7aa7c39a2 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'; \ No newline at end of file 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 000000000..8cae6ae41 --- /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(); + 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 index 149af685c..4c3b0e51a 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -61,15 +61,15 @@ export async function* streamPageJourneySuggestions({ }) ) .describe( - 'A list of pages in the journey, starting with the current page.' + 'A list of pages in the journey, excluding the current page.' ) .min(5) .max(10), }) ) .describe('The possible journeys to take through the documentation.') - .min(4) - .max(4), + .min(count) + .max(count), }), tools: { getPages: true, diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index 05f8295c7..32a6f9300 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -46,6 +46,7 @@ export function PageAside(props: { 'text-tint', 'contrast-more:text-tint-strong', + 'text-sm', 'sticky', // Without header @@ -79,8 +80,8 @@ export function PageAside(props: { 'page-api-block:p-2' )} > -
- {customization.ai.adaptivePane ? : null} +
+ {customization.ai.adaptivePane ? : null} {page.layout.outline ? ( <> diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index fe6934a52..83a9d947d 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 ( - {withFullPageCover && page.cover ? ( - - ) : null} - {/* We use a flex row reverse to render the aside first because the page is streamed. */} -
- - -
- - - + + {withFullPageCover && page.cover ? ( + + ) : null} + {/* We use a flex row reverse to render the aside first because the page is streamed. */} +
+ + +
+ + + +
); } @@ -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 966adf012..d0596cca9 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', + scaleIn: 'scaleIn 200ms ease both', + scaleOut: 'scaleOut 200ms ease both', fadeIn: 'fadeIn 200ms ease both', - fadeOut: 'fadeOut 200ms ease forwards', - enterFromLeft: 'enterFromLeft 250ms ease', - enterFromRight: 'enterFromRight 250ms ease', - exitToLeft: 'exitToLeft 250ms ease', - exitToRight: 'exitToRight 250ms ease', + 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: {