Third iteration

This commit is contained in:
Zeno Kapitein
2025-04-09 14:30:40 +02:00
parent 0e15229c52
commit cb2cc52c26
11 changed files with 551 additions and 209 deletions
@@ -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<SuggestedPage[]>(
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 (
<AnimatePresence initial={false}>
{open && (
<motion.div
key="next-page-suggestions"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<div className="relative mb-2 flex flex-row items-center gap-3">
{selectedJourney?.icon ? (
<Icon
key={selectedJourney.icon}
icon={selectedJourney.icon as IconName}
className="absolute left-0 size-6 animate-scaleIn text-tint-subtle [animation-delay:100ms]"
/>
) : null}
<div
className={tcls(
'flex flex-col transition-all',
selectedJourney?.icon ? 'ml-9' : 'delay-0'
)}
>
<div className="flex flex-row items-center gap-2 font-semibold text-tint text-xs uppercase tracking-wide">
Suggested pages
</div>
{selectedJourney?.label ? (
<h5
key={selectedJourney.label}
className="animate-fadeIn font-semibold text-base"
>
{selectedJourney.label}
</h5>
) : null}
</div>
</div>
<div className="-mb-1.5 flex flex-col gap-1">
{pages.map((page, index) =>
page?.id ? (
<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 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>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -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 (
<div>
<div className="grid w-72 grid-cols-2 gap-2 text-sm">
{journeys.map((journey, i) => (
<button
type="button"
key={i}
disabled={journey?.label === undefined}
className={tcls(
'flex flex-col items-center justify-center gap-2 rounded border border-tint-subtle px-2 py-4 text-center transition-all duration-500 *:animate-fadeIn *:delay-200',
journey?.label === undefined
? 'h-24 scale-90 animate-pulse'
: 'duration-300 hover:border-tint hover:bg-tint-active hover:text-tint-strong',
journey?.label &&
journey.label === selected?.label &&
'border-tint bg-tint-active text-tint-strong'
)}
style={{
animationDelay: `${i * -0.2}s`,
}}
onClick={() => setSelected(journey)}
>
{journey?.icon ? (
<Icon
icon={journey.icon as IconName}
className="size-4 text-tint-subtle"
/>
) : null}
{journey?.label}
</button>
))}
</div>
{selected && (
<div className="mt-6 animate-present text-sm [animation-duration:1000ms]">
<h3 className="font-bold text-base">
{selected.icon ? (
<Icon
icon={selected.icon as IconName}
className="mr-2 inline size-5 text-tint-subtle"
/>
) : null}
{selected.label}
</h3>
<ol className="mt-2 ml-2 flex flex-col gap-2 border-tint-subtle border-l pl-5">
{selected.pages?.map((page, index) => (
<li
key={selected.label + page.id}
className="animate-fadeIn [animation-duration:500ms]"
style={{ animationDelay: `${index * 0.1}s` }}
>
<Link href={page.href} className="flex gap-2">
<Icon icon={page.icon as IconName} className="size-4" />
{page.title}
</Link>
</li>
))}
</ol>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
key="page-journey-suggestions"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<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">
{journeys.map((journey, i) => {
const isSelected =
journey?.label && journey.label === selectedJourney?.label;
const isLoading = journey?.label === undefined;
return (
<button
type="button"
key={i}
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',
isSelected &&
'bg-primary-active text-primary-strong ring-2 ring-primary hover:bg-primary-active hover:ring-primary'
)}
style={{
animationDelay: `${i * 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 [animation-delay:400ms]">
{journey.label}
</span>
) : null}
</button>
);
})}
</div>
</motion.div>
)}
</div>
</AnimatePresence>
);
}
@@ -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<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);
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[]>(
Array.from({ length: JOURNEY_COUNT })
);
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;
(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 (
<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;
}
@@ -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 (
<>
<div>
<div className="mb-2 flex flex-row items-center gap-2 font-semibold text-xs uppercase tracking-wide">
<Icon icon="map" className="size-3" />
More to explore
</div>
<AIPageJourneySuggestions spaces={getSpaces(context.structure)} />
</div>
</>
);
}
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,
}))
<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 p-3'
)}
>
<AdaptivePaneHeader />
<AIPageJourneySuggestions />
<AINextPageSuggestions />
</div>
);
}
@@ -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 (
<div
className={tcls(
'flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500',
open ? '' : ''
)}
>
<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>
);
}
@@ -1 +1,3 @@
export * from './AIPageLinkSummary';
export * from './AdaptiveContext';
export * from './AdaptivePane';
@@ -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,
}),
};
}
}
}
@@ -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,
@@ -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'
)}
>
<div className='lg:top:0 sticky flex 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'>
{customization.ai.adaptivePane ? <AdaptivePane context={context} /> : null}
<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'>
{customization.ai.adaptivePane ? <AdaptivePane /> : null}
{page.layout.outline ? (
<>
<PageOutline document={document} context={context} />
@@ -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,
}))
);
}
+7 -7
View File
@@ -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: {