mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Third iteration
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user