From 8547867c6dd66218ee7111886c2b7fa559450aab Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 8 Apr 2025 18:25:14 +0200 Subject: [PATCH 01/14] Initial version --- packages/gitbook-v2/package.json | 2 +- packages/gitbook-v2/src/lib/data/api.ts | 1 + packages/gitbook-v2/src/lib/data/types.ts | 1 + .../Adaptive/AIPageJourneySuggestions.tsx | 82 ++++++++ .../src/components/Adaptive/AdaptivePane.tsx | 33 +++ .../components/Adaptive/server-actions/api.ts | 12 +- .../Adaptive/server-actions/index.ts | 1 + .../streamPageJourneySuggestions.ts | 128 ++++++++++++ .../src/components/PageAside/PageActions.tsx | 105 ++++++++++ .../src/components/PageAside/PageAside.tsx | 193 ++---------------- .../src/components/PageAside/PageOutline.tsx | 51 +++++ 11 files changed, 433 insertions(+), 176 deletions(-) create mode 100644 packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx create mode 100644 packages/gitbook/src/components/Adaptive/AdaptivePane.tsx create mode 100644 packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts create mode 100644 packages/gitbook/src/components/PageAside/PageActions.tsx create mode 100644 packages/gitbook/src/components/PageAside/PageOutline.tsx diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 50cf96a0d7..71ffa1045a 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -24,7 +24,7 @@ }, "scripts": { "generate": "rm -rf ./public && cp -r ../gitbook/public ./public", - "dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack", + "dev:v2": "env-cmd --silent -f ../../.env.local next", "build": "next build", "build:v2": "next build", "start": "next start", 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; } diff --git a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx new file mode 100644 index 0000000000..389bf1d20b --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import { useEffect } from 'react'; +import { useState } from 'react'; +import { useVisitedPages } from '../Insights'; +import { usePageContext } from '../PageContext'; +import { streamPageJourneySuggestions } from './server-actions'; + +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<({ label?: string; icon?: string } | undefined)[]>([]); + + useEffect(() => { + let canceled = false; + + (async () => { + const stream = await streamPageJourneySuggestions({ + currentPage: { + id: currentPage.pageId, + title: currentPage.title, + }, + currentSpace: { + id: currentPage.spaceId, + }, + allSpaces: spaces, + visitedPages, + }); + + for await (const journeys of stream) { + if (canceled) return; + setJourneys(journeys); + } + })(); + + return () => { + canceled = true; + }; + }, [currentPage.pageId, currentPage.spaceId, visitedPages, spaces]); + + const shimmerBlocks = [ + '[animation-delay:-.2s]', + '[animation-delay:-.4s]', + '[animation-delay:-.6s]', + '[animation-delay:-.8s]', + ]; + + return ( +
+ {shimmerBlocks.map((block, i) => + journeys[i]?.icon ? ( +
+ + {journeys[i].label} +
+ ) : ( +
+ ) + )} +
+ ); +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx new file mode 100644 index 0000000000..2327ace497 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -0,0 +1,33 @@ +import type { SiteStructure } from '@gitbook/api'; +import type { GitBookSiteContext } from '@v2/lib/context'; +import { AIPageJourneySuggestions } from './AIPageJourneySuggestions'; + +export function AdaptivePane(props: { context: GitBookSiteContext }) { + const { context } = props; + + return ( +
+ +
+ ); +} + +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/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( schema, messages, model = AIModel.Fast, + tools = {}, }: { schema: z.ZodSchema; messages: AIMessageInput[]; model?: AIModel; previousResponseId?: string; + tools?: AIToolCapabilities; } ) { const rawStream = context.dataFetcher.streamAIResponse({ @@ -62,12 +69,13 @@ export async function streamGenerateObject( type: 'object', schema: zodToJsonSchema(schema), }, + tools, model, }); let json = ''; return parseResponse>(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..c427373043 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'; \ No newline at end of file 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..d0a98411d4 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -0,0 +1,128 @@ +'use server'; +import { getV1BaseContext } from '@/lib/v1'; +import { isV2 } from '@/lib/v2'; +import { AIMessageRole } from '@gitbook/api'; +import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; +import { getServerActionBaseContext } from '@v2/lib/server-actions'; +import { z } from 'zod'; +import { streamGenerateObject } from './api'; + +/** + * Get a summary of a page, in the context of another page + */ +export async function* streamPageJourneySuggestions({ + currentPage, + currentSpace, + allSpaces, + visitedPages, +}: { + currentPage: { + id: string; + title: string; + }; + currentSpace: { + id: string; + // title: string; + }; + allSpaces: { + id: string; + title: string; + }[]; + visitedPages?: Array<{ spaceId: string; pageId: string }>; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const { stream } = await streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + 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' + ), + }) + ) + .describe('The possible journeys to take through the documentation.') + .max(4), + }), + 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 "${currentSpace.title}"`, + }, + { + 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, + })), + }, + ] + : []), + ], + } + ); + + // const emitted = new Set(); + for await (const value of stream) { + const journeys = value.journeys; + if (!journeys) { + continue; + } + + // for (const journey of journeys) { + // if (emitted.has(journey)) { + // continue; + // } + + // emitted.add(journey); + // yield journey; + // } + yield journeys; + } +} diff --git a/packages/gitbook/src/components/PageAside/PageActions.tsx b/packages/gitbook/src/components/PageAside/PageActions.tsx new file mode 100644 index 0000000000..0a2db60053 --- /dev/null +++ b/packages/gitbook/src/components/PageAside/PageActions.tsx @@ -0,0 +1,105 @@ +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 { 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 ( +
+ {withPageFeedback ? ( + + + + ) : null} + {/* {customization.git.showEditLink && space.gitSync?.url && page.git ? ( + + ) : null} */} + {customization.pdf.enabled ? ( + + ) : 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/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index a51410cbc0..05f8295c72 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. @@ -31,28 +26,20 @@ export function PageAside(props: { withFullPageCover: boolean; withPageFeedback: boolean; }) { - const { page, document, withPageFeedback, context } = props; + const { page, document, withPageFeedback, withFullPageCover, withHeaderOffset, 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()}` - ); + customization.ai.adaptivePane = true; + return (
); } - -async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) { - const { document, context } = props; - - const sections = await getDocumentSections(context, document); - - return sections.length > 1 ? : 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..1fb535647b --- /dev/null +++ b/packages/gitbook/src/components/PageAside/PageOutline.tsx @@ -0,0 +1,51 @@ +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 function PageOutline(props: { + document: JSONDocument | null; + context: GitBookSiteContext; +}) { + const { document, context } = props; + const { customization } = context; + const language = getSpaceLanguage(customization); + + return ( +
+
+ + {t(language, 'on_this_page')} +
+
+ {document ? ( + + + + ) : null} +
+
+ ); +} + +async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) { + const { document, context } = props; + + const sections = await getDocumentSections(context, document); + + return sections.length > 1 ? : null; +} From 0e15229c529547176779c463b421315e29cbf643 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 8 Apr 2025 21:27:45 +0200 Subject: [PATCH 02/14] Second iteration --- .../Adaptive/AIPageJourneySuggestions.tsx | 130 +++++++--- .../src/components/Adaptive/AdaptivePane.tsx | 13 +- .../streamPageJourneySuggestions.ts | 240 ++++++++++++------ .../src/components/PageAside/PageOutline.tsx | 28 +- packages/gitbook/tailwind.config.ts | 2 +- 5 files changed, 276 insertions(+), 137 deletions(-) diff --git a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx index 389bf1d20b..57516675af 100644 --- a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx +++ b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx @@ -1,13 +1,15 @@ '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'; +const JOURNEY_COUNT = 4; + export function AIPageJourneySuggestions(props: { spaces: { id: string; title: string }[] }) { const { spaces } = props; @@ -15,13 +17,40 @@ export function AIPageJourneySuggestions(props: { spaces: { id: string; title: s // const language = useLanguage(); const visitedPages = useVisitedPages((state) => state.pages); - const [journeys, setJourneys] = useState<({ label?: string; icon?: string } | undefined)[]>([]); + 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, @@ -33,49 +62,84 @@ export function AIPageJourneySuggestions(props: { spaces: { id: string; title: s visitedPages, }); - for await (const journeys of stream) { + for await (const journey of stream) { if (canceled) return; - setJourneys(journeys); + + // 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, visitedPages, spaces]); - - const shimmerBlocks = [ - '[animation-delay:-.2s]', - '[animation-delay:-.4s]', - '[animation-delay:-.6s]', - '[animation-delay:-.8s]', - ]; + }, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]); return ( -
- {shimmerBlocks.map((block, i) => - journeys[i]?.icon ? ( -
- - {journeys[i].label} -
- ) : ( -
+
+ {journeys.map((journey, i) => ( + + ))} +
+ {selected && ( +
+

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

+
    + {selected.pages?.map((page, index) => ( +
  1. + + + {page.title} + +
  2. + ))} +
+
)}
); diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx index 2327ace497..174169e352 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -1,4 +1,5 @@ import type { SiteStructure } from '@gitbook/api'; +import { Icon } from '@gitbook/icons'; import type { GitBookSiteContext } from '@v2/lib/context'; import { AIPageJourneySuggestions } from './AIPageJourneySuggestions'; @@ -6,9 +7,15 @@ export function AdaptivePane(props: { context: GitBookSiteContext }) { const { context } = props; return ( -
- -
+ <> +
+
+ + More to explore +
+ +
+ ); } diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts index d0a98411d4..149af685ca 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -1,9 +1,10 @@ 'use server'; +import { type AncestorRevisionPage, resolvePageId } from '@/lib/pages'; import { getV1BaseContext } from '@/lib/v1'; import { isV2 } from '@/lib/v2'; -import { AIMessageRole } from '@gitbook/api'; +import { AIMessageRole, type RevisionPageDocument } from '@gitbook/api'; import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware'; -import { getServerActionBaseContext } from '@v2/lib/server-actions'; +import { fetchServerActionSiteContext, getServerActionBaseContext } from '@v2/lib/server-actions'; import { z } from 'zod'; import { streamGenerateObject } from './api'; @@ -15,6 +16,7 @@ export async function* streamPageJourneySuggestions({ currentSpace, allSpaces, visitedPages, + count, }: { currentPage: { id: string; @@ -29,100 +31,172 @@ export async function* streamPageJourneySuggestions({ title: string; }[]; visitedPages?: Array<{ spaceId: string; pageId: string }>; + count: number; }) { const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); const siteURLData = await getSiteURLDataFromMiddleware(); - const { stream } = await streamGenerateObject( - baseContext, - { - organizationId: siteURLData.organization, - siteId: siteURLData.site, - }, - { - schema: z.object({ - 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' - ), - }) - ) - .describe('The possible journeys to take through the documentation.') - .max(4), - }), - tools: { - getPages: true, - getPageContent: true, + const [{ stream }, context] = await Promise.all([ + streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, }, - 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 "${currentSpace.title}"`, + { + 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, starting with the current page.' + ) + .min(5) + .max(10), + }) + ) + .describe('The possible journeys to take through the documentation.') + .min(4) + .max(4), + }), + tools: { + getPages: true, + getPageContent: true, }, - { - role: AIMessageRole.Developer, - content: `Other spaces in the documentation are: ${allSpaces - .map( - (space) => ` + 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')} + ) + .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, - })), - }, - ] - : []), - ], - } - ); + }, + { + 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[] }[] = []; + // for await (const value of stream) { + // const journeys = value.journeys; + + // if (!journeys) continue; + + // yield journeys; + // } - // const emitted = new Set(); for await (const value of stream) { const journeys = value.journeys; - if (!journeys) { - continue; - } - // for (const journey of journeys) { - // if (emitted.has(journey)) { - // continue; - // } + if (!journeys) continue; + + for (const journey of journeys) { + if (!journey?.label) continue; + if (!journey?.pages) continue; + if (emitted.find((item) => item.label === journey.label)) continue; - // emitted.add(journey); - // yield journey; - // } - yield journeys; + const pageIds: string[] = []; + const resolvedPages: { + page: RevisionPageDocument; + ancestors: AncestorRevisionPage[]; + }[] = []; + for (const page of journey.pages) { + if (!page) continue; + if (!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, + }); + + + yield { + label: journey.label, + icon: journey.icon, + pages: resolvedPages.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, + }), + })), + }; + } } + + // for await (const value of stream) { + // const journeys = value.journeys; + + // if (!journeys) continue; + + // // for (const journey of journeys) { + // // if (emitted.has(journey)) { + // // continue; + // // } + + // // emitted.add(journey); + // // yield journey; + // // } + // } } diff --git a/packages/gitbook/src/components/PageAside/PageOutline.tsx b/packages/gitbook/src/components/PageAside/PageOutline.tsx index 1fb535647b..616fe63810 100644 --- a/packages/gitbook/src/components/PageAside/PageOutline.tsx +++ b/packages/gitbook/src/components/PageAside/PageOutline.tsx @@ -7,7 +7,7 @@ import type { GitBookSiteContext } from '@v2/lib/context'; import React from 'react'; import { ScrollSectionsList } from './ScrollSectionsList'; -export function PageOutline(props: { +export async function PageOutline(props: { document: JSONDocument | null; context: GitBookSiteContext; }) { @@ -15,7 +15,11 @@ export function PageOutline(props: { const { customization } = context; const language = getSpaceLanguage(customization); - return ( + if(!document) return; + + const sections = await getDocumentSections(context, document); + + return document && sections.length > 1 ? (
@@ -32,20 +36,10 @@ export function PageOutline(props: { // 'page-api-block:xl:max-2xl:group-hover/aside:flex' )} > - {document ? ( - - - - ) : null} + + +
- ); -} - -async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) { - const { document, context } = props; - - const sections = await getDocumentSections(context, document); - - return sections.length > 1 ? : null; -} + ) : null; +} \ No newline at end of file diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 2e5b7c359f..966adf012c 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -298,7 +298,7 @@ const config: Config = { 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', + fadeIn: 'fadeIn 200ms ease both', fadeOut: 'fadeOut 200ms ease forwards', enterFromLeft: 'enterFromLeft 250ms ease', enterFromRight: 'enterFromRight 250ms ease', From cb2cc52c260a59eb5218d482bf3c402d04a8cb4b Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 9 Apr 2025 14:30:40 +0200 Subject: [PATCH 03/14] Third iteration --- .../Adaptive/AINextPageSuggestions.tsx | 128 ++++++++++++ .../Adaptive/AIPageJourneySuggestions.tsx | 194 +++++------------- .../components/Adaptive/AdaptiveContext.tsx | 108 ++++++++++ .../src/components/Adaptive/AdaptivePane.tsx | 53 ++--- .../Adaptive/AdaptivePaneHeader.tsx | 45 ++++ .../gitbook/src/components/Adaptive/index.ts | 2 + .../streamNextPageSuggestions.ts | 128 ++++++++++++ .../streamPageJourneySuggestions.ts | 6 +- .../src/components/PageAside/PageAside.tsx | 5 +- .../src/components/SitePage/SitePage.tsx | 77 ++++--- packages/gitbook/tailwind.config.ts | 14 +- 11 files changed, 551 insertions(+), 209 deletions(-) create mode 100644 packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx create mode 100644 packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx create mode 100644 packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx create mode 100644 packages/gitbook/src/components/Adaptive/server-actions/streamNextPageSuggestions.ts diff --git a/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx new file mode 100644 index 0000000000..49f2dfca8b --- /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 57516675af..33d0a6299a 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 0000000000..9d14655987 --- /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 174169e352..08c927a0ce 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 0000000000..02ccdf9070 --- /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 2d93029d7e..7aa7c39a2a 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 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(); + 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 149af685ca..4c3b0e51a4 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 05f8295c72..32a6f93000 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 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 ( - {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 966adf012c..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', + 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: { From 83e02b621aec73692a0306499ed20a7c274efa7a Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 9 Apr 2025 16:23:23 +0200 Subject: [PATCH 04/14] Iteration 4 --- .../Adaptive/AINextPageSuggestions.tsx | 140 ++++++++++-------- .../Adaptive/AIPageJourneySuggestions.tsx | 44 +++--- .../components/Adaptive/AdaptiveContext.tsx | 19 +-- .../src/components/Adaptive/AdaptivePane.tsx | 2 +- .../Adaptive/AdaptivePaneHeader.tsx | 7 +- .../streamPageJourneySuggestions.ts | 38 ++--- .../src/components/PageAside/PageActions.tsx | 5 +- .../src/components/PageAside/PageAside.tsx | 6 +- 8 files changed, 121 insertions(+), 140 deletions(-) diff --git a/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx index 49f2dfca8b..04e0e776a6 100644 --- a/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx +++ b/packages/gitbook/src/components/Adaptive/AINextPageSuggestions.tsx @@ -16,89 +16,97 @@ export function AINextPageSuggestions() { const currentPage = usePageContext(); const visitedPages = useVisitedPages((state) => state.pages); - const [pages, setPages] = useState( - selectedJourney?.pages ?? Array.from({ length: 5 }) - ); + const [pages, setPages] = useState(selectedJourney?.pages ?? []); + const [suggestedPages, setSuggestedPages] = useState([]); useEffect(() => { let canceled = false; if (selectedJourney?.pages && selectedJourney.pages.length > 0) { setPages(selectedJourney.pages); + } else { + setPages(suggestedPages); } - (async () => { - const stream = await streamNextPageSuggestions({ - currentPage: { - id: currentPage.pageId, - title: currentPage.title, - }, - currentSpace: { - id: currentPage.spaceId, - }, - visitedPages: visitedPages, - }); + 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; + 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; - }); - } - })(); + setPages((prev) => [...prev, page]); + setSuggestedPages((prev) => [...prev, page]); + } + })(); + } return () => { canceled = true; }; - }, [selectedJourney, currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages]); + }, [ + selectedJourney, + currentPage.pageId, + currentPage.spaceId, + currentPage.title, + visitedPages, + suggestedPages, + ]); return ( - - {open && ( - -
+ open && ( +
+ + {selectedJourney?.icon ? ( - + > + + ) : null} -
-
- Suggested pages -
+ + +
+ Suggested pages +
+ {selectedJourney?.label ? ( -
{selectedJourney.label} -
+ ) : null} -
-
-
- {pages.map((page, index) => - page?.id ? ( + + + +
+ {Object.assign(Array.from({ length: 5 }), pages).map( + (page: SuggestedPage | undefined, index) => + page ? ( ) : null} {page.emoji ? : null} @@ -116,13 +124,15 @@ export function AINextPageSuggestions() {
) - )} -
- - )} - + )} +
+
+ ) ); } diff --git a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx index 33d0a6299a..a00d4721ea 100644 --- a/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx +++ b/packages/gitbook/src/components/Adaptive/AIPageJourneySuggestions.tsx @@ -1,44 +1,38 @@ 'use client'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useAdaptiveContext } from './AdaptiveContext'; +import { JOURNEY_COUNT, type Journey, useAdaptiveContext } from './AdaptiveContext'; export function AIPageJourneySuggestions() { const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext(); return ( - - {open && ( - -
- More to explore -
-
- {journeys.map((journey, i) => { + open && ( +
+
+ More to explore +
+
+ {Object.assign(Array.from({ length: JOURNEY_COUNT }), journeys).map( + (journey: Journey | undefined, index) => { const isSelected = journey?.label && journey.label === selectedJourney?.label; - const isLoading = journey?.label === undefined; + const isLoading = !journey || journey?.label === undefined; return ( ); - })} -
- - )} - + } + )} +
+
+ ) ); } diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx index 9d14655987..002818937c 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx @@ -13,7 +13,7 @@ export type SuggestedPage = { emoji?: string; }; -type Journey = { +export type Journey = { label: string; icon?: string; pages?: Array; @@ -30,7 +30,7 @@ type AdaptiveContextType = { export const AdaptiveContext = React.createContext(null); -const JOURNEY_COUNT = 4; +export const JOURNEY_COUNT = 4; /** * Client side context provider to pass information about the current page. @@ -39,9 +39,7 @@ export function JourneyContextProvider({ children, spaces, }: { children: React.ReactNode; spaces: { id: string; title: string }[] }) { - const [journeys, setJourneys] = React.useState( - Array.from({ length: JOURNEY_COUNT }) - ); + const [journeys, setJourneys] = React.useState([]); const [selectedJourney, setSelectedJourney] = React.useState(undefined); const [loading, setLoading] = React.useState(true); const [open, setOpen] = React.useState(true); @@ -52,6 +50,8 @@ export function JourneyContextProvider({ useEffect(() => { let canceled = false; + setJourneys([]); + (async () => { const stream = await streamPageJourneySuggestions({ count: JOURNEY_COUNT, @@ -69,14 +69,7 @@ export function JourneyContextProvider({ 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; - }); + setJourneys((prev) => [...prev, journey]); } setLoading(false); diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx index 08c927a0ce..c56134c8a8 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -12,7 +12,7 @@ export function AdaptivePane() {
diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx index 02ccdf9070..0141b13165 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptivePaneHeader.tsx @@ -9,12 +9,7 @@ export function AdaptivePaneHeader() { const { loading, open, setOpen } = useAdaptiveContext(); return ( -
+

diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts index 4c3b0e51a4..0851d3118b 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -61,7 +61,7 @@ export async function* streamPageJourneySuggestions({ }) ) .describe( - 'A list of pages in the journey, excluding the current page.' + 'A list of pages in the journey, excluding the current page. Try to avoid duplicate content that is very similar.' ) .min(5) .max(10), @@ -127,13 +127,7 @@ Feel free to create journeys across spaces.`, ]); const emitted: { label: string; pageIds: string[] }[] = []; - // for await (const value of stream) { - // const journeys = value.journeys; - - // if (!journeys) continue; - - // yield journeys; - // } + const allEmittedPageIds = new Set(); for await (const value of stream) { const journeys = value.journeys; @@ -142,7 +136,7 @@ Feel free to create journeys across spaces.`, for (const journey of journeys) { if (!journey?.label) continue; - if (!journey?.pages) continue; + if (!journey?.pages || journey.pages?.length === 0) continue; if (emitted.find((item) => item.label === journey.label)) continue; const pageIds: string[] = []; @@ -153,6 +147,7 @@ Feel free to create journeys across spaces.`, for (const page of journey.pages) { if (!page) continue; if (!page.id) continue; + if (pageIds.includes(page.id)) continue; pageIds.push(page.id); @@ -167,11 +162,19 @@ Feel free to create journeys across spaces.`, 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: resolvedPages.map((page) => ({ + pages: uniquePages.map((page) => ({ id: page.page.id, title: page.page.title, icon: page.page.icon, @@ -184,19 +187,4 @@ Feel free to create journeys across spaces.`, }; } } - - // for await (const value of stream) { - // const journeys = value.journeys; - - // if (!journeys) continue; - - // // for (const journey of journeys) { - // // if (emitted.has(journey)) { - // // continue; - // // } - - // // emitted.add(journey); - // // yield journey; - // // } - // } } diff --git a/packages/gitbook/src/components/PageAside/PageActions.tsx b/packages/gitbook/src/components/PageAside/PageActions.tsx index 0a2db60053..31cf9f7d95 100644 --- a/packages/gitbook/src/components/PageAside/PageActions.tsx +++ b/packages/gitbook/src/components/PageAside/PageActions.tsx @@ -4,6 +4,7 @@ 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'; @@ -40,7 +41,7 @@ export function PageActions(props: { ) : null} - {/* {customization.git.showEditLink && space.gitSync?.url && page.git ? ( + {customization.git.showEditLink && space.gitSync?.url && page.git ? ( - ) : null} */} + ) : null} {customization.pdf.enabled ? (
-
- {customization.ai.adaptivePane ? : null} +
+ {useAdaptivePane ? : null} {page.layout.outline ? ( <> From 3cdba0ae8e774ab8ccc13584ed82a13d33a2a1d1 Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 9 Apr 2025 16:25:52 +0200 Subject: [PATCH 05/14] Cleanup & format --- .changeset/late-cherries-rule.md | 5 +++++ packages/gitbook-v2/package.json | 4 ++-- packages/gitbook/src/components/Adaptive/index.ts | 2 +- .../gitbook/src/components/Adaptive/server-actions/index.ts | 2 +- .../Adaptive/server-actions/streamPageJourneySuggestions.ts | 2 +- packages/gitbook/src/components/PageAside/PageAside.tsx | 3 +-- packages/gitbook/src/components/PageAside/PageOutline.tsx | 6 +++--- 7 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 .changeset/late-cherries-rule.md 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/package.json b/packages/gitbook-v2/package.json index 71ffa1045a..1193267cae 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -24,7 +24,7 @@ }, "scripts": { "generate": "rm -rf ./public && cp -r ../gitbook/public ./public", - "dev:v2": "env-cmd --silent -f ../../.env.local next", + "dev:v2": "env-cmd --silent -f ../../.env.local next --turbopack", "build": "next build", "build:v2": "next build", "start": "next start", @@ -33,4 +33,4 @@ "unit": "bun test", "typecheck": "tsc --noEmit" } -} +} \ No newline at end of file diff --git a/packages/gitbook/src/components/Adaptive/index.ts b/packages/gitbook/src/components/Adaptive/index.ts index 7aa7c39a2a..cf7f351a2d 100644 --- a/packages/gitbook/src/components/Adaptive/index.ts +++ b/packages/gitbook/src/components/Adaptive/index.ts @@ -1,3 +1,3 @@ export * from './AIPageLinkSummary'; export * from './AdaptiveContext'; -export * from './AdaptivePane'; \ No newline at end of file +export * from './AdaptivePane'; diff --git a/packages/gitbook/src/components/Adaptive/server-actions/index.ts b/packages/gitbook/src/components/Adaptive/server-actions/index.ts index c427373043..81f5686fc1 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/index.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/index.ts @@ -1,2 +1,2 @@ export * from './streamLinkPageSummary'; -export * from './streamPageJourneySuggestions'; \ No newline at end of file +export * from './streamPageJourneySuggestions'; diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts index 0851d3118b..08649217ca 100644 --- a/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageJourneySuggestions.ts @@ -163,7 +163,7 @@ Feel free to create journeys across spaces.`, }); // Deduplicate pages before yielding - const uniquePages = resolvedPages.filter(page => { + const uniquePages = resolvedPages.filter((page) => { if (allEmittedPageIds.has(page.page.id)) { return false; } diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index e003b7de2f..99cb7a2a25 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -26,8 +26,7 @@ export function PageAside(props: { withFullPageCover: boolean; withPageFeedback: boolean; }) { - const { page, document, withPageFeedback, withFullPageCover, withHeaderOffset, context } = - props; + const { page, document, withPageFeedback, context } = props; const { customization, site, space } = context; const useAdaptivePane = true; diff --git a/packages/gitbook/src/components/PageAside/PageOutline.tsx b/packages/gitbook/src/components/PageAside/PageOutline.tsx index 616fe63810..84c40a25b1 100644 --- a/packages/gitbook/src/components/PageAside/PageOutline.tsx +++ b/packages/gitbook/src/components/PageAside/PageOutline.tsx @@ -15,8 +15,8 @@ export async function PageOutline(props: { const { customization } = context; const language = getSpaceLanguage(customization); - if(!document) return; - + if (!document) return; + const sections = await getDocumentSections(context, document); return document && sections.length > 1 ? ( @@ -42,4 +42,4 @@ export async function PageOutline(props: {
) : null; -} \ No newline at end of file +} From d8cfb1097482a547dec82a8c6ead034ba8be6a1f Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 9 Apr 2025 16:49:39 +0200 Subject: [PATCH 06/14] Format --- packages/gitbook-v2/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 1193267cae..50cf96a0d7 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -33,4 +33,4 @@ "unit": "bun test", "typecheck": "tsc --noEmit" } -} \ No newline at end of file +} From fbf6951c71d2df97e3503b05e44b2626045b958f Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Tue, 6 May 2025 16:55:06 +0200 Subject: [PATCH 07/14] First pass --- .../src/components/Adaptive/AIPageSummary.tsx | 69 ++++++ .../components/Adaptive/AdaptiveContext.tsx | 39 ++-- .../src/components/Adaptive/AdaptivePane.tsx | 8 +- .../server-actions/streamPageSummary.ts | 209 ++++++++++++++++++ .../src/components/PageAside/PageAside.tsx | 2 +- 5 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 packages/gitbook/src/components/Adaptive/AIPageSummary.tsx create mode 100644 packages/gitbook/src/components/Adaptive/server-actions/streamPageSummary.ts diff --git a/packages/gitbook/src/components/Adaptive/AIPageSummary.tsx b/packages/gitbook/src/components/Adaptive/AIPageSummary.tsx new file mode 100644 index 0000000000..8f0eae0ebf --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/AIPageSummary.tsx @@ -0,0 +1,69 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useVisitedPages } from '../Insights'; +import { usePageContext } from '../PageContext'; +import { useAdaptiveContext } from './AdaptiveContext'; +import { streamPageSummary } from './server-actions/streamPageSummary'; + +export function AIPageSummary() { + const { open } = useAdaptiveContext(); + + const currentPage = usePageContext(); + const visitedPages = useVisitedPages((state) => state.pages); + + const [summary, setSummary] = useState<{ + pageSummary?: string; + bigPicture?: string; + }>({}); + + useEffect(() => { + let canceled = false; + + (async () => { + const stream = await streamPageSummary({ + currentPage: { + id: currentPage.pageId, + title: currentPage.title, + }, + currentSpace: { + id: currentPage.spaceId, + }, + visitedPages: visitedPages, + }); + + for await (const summary of stream) { + if (canceled) return; + + setSummary(summary); + } + })(); + + return () => { + canceled = true; + }; + }, [currentPage, visitedPages]); + + return ( + open && ( +
+ {summary.pageSummary ? ( +
+
+ Key facts +
+ {summary.pageSummary} +
+ ) : null} + + {visitedPages.length > 1 && summary?.bigPicture ? ( +
+
+ Big Picture +
+ {summary?.bigPicture} +
+ ) : null} +
+ ) + ); +} diff --git a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx index 002818937c..22b883cb8e 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptiveContext.tsx @@ -3,7 +3,6 @@ import React, { useEffect } from 'react'; import { useVisitedPages } from '../Insights'; import { usePageContext } from '../PageContext'; -import { streamPageJourneySuggestions } from './server-actions'; export type SuggestedPage = { id: string; @@ -50,27 +49,27 @@ export function JourneyContextProvider({ useEffect(() => { let canceled = false; - setJourneys([]); + // 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]); - } + // 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); })(); diff --git a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx index c56134c8a8..628bb4a5a4 100644 --- a/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx +++ b/packages/gitbook/src/components/Adaptive/AdaptivePane.tsx @@ -1,8 +1,7 @@ 'use client'; import { tcls } from '@/lib/tailwind'; -import { AINextPageSuggestions } from './AINextPageSuggestions'; -import { AIPageJourneySuggestions } from './AIPageJourneySuggestions'; +import { AIPageSummary } from './AIPageSummary'; import { useAdaptiveContext } from './AdaptiveContext'; import { AdaptivePaneHeader } from './AdaptivePaneHeader'; export function AdaptivePane() { @@ -16,8 +15,9 @@ export function AdaptivePane() { )} > - - + + {/* + */}
); } diff --git a/packages/gitbook/src/components/Adaptive/server-actions/streamPageSummary.ts b/packages/gitbook/src/components/Adaptive/server-actions/streamPageSummary.ts new file mode 100644 index 0000000000..bc9051cb45 --- /dev/null +++ b/packages/gitbook/src/components/Adaptive/server-actions/streamPageSummary.ts @@ -0,0 +1,209 @@ +'use server'; +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 summary of a page, in the context of another page + */ +export async function* streamPageSummary({ + currentPage, + currentSpace, + visitedPages, +}: { + currentPage: { + id: string; + title: string; + }; + currentSpace: { + id: string; + // title: string; + }; + visitedPages: { + pageId: string; + spaceId: string; + }[]; +}) { + const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext(); + const siteURLData = await getSiteURLDataFromMiddleware(); + + const [{ stream }] = await Promise.all([ + streamGenerateObject( + baseContext, + { + organizationId: siteURLData.organization, + siteId: siteURLData.site, + }, + { + schema: z.object({ + pageSummary: z + .string() + .describe( + 'A collection of key facts from the page that together form a comprehensive summary. Keep it under 30 words.' + ), + bigPicture: + visitedPages.length > 0 + ? z + .string() + .describe( + 'A natural-sounding summary of how specific concepts connect with real benefits. Use a conversational tone with concrete examples. Avoid overly formal language while still being specific. Keep it under 30 words.' + ) + : z.undefined(), + }), + tools: { + // getPages: true, + // getPageContent: true, + }, + messages: [ + { + role: AIMessageRole.Developer, + content: `# 1. Role + You are a fact extractor. Your job is to identify and extract the most important facts from the current page. + + # 2. Task + Extract multiple key facts that: + - Cover the most important concepts, features, or capabilities on the page + - Represent specific, actionable information rather than general descriptions + - Provide concrete details about functionality, limitations, or specifications + - Together form a comprehensive understanding of the page content + - Relate to the user's learning journey through the documentation when relevant + + # 3. Instructions + 1. Analyze the current page to identify 3-5 concrete, specific facts (not general summaries) + 2. Focus on facts that would be most useful and relevant to someone using this documentation + 3. If the user has visited other pages, identify facts that build upon their previous knowledge + 4. Present facts as clear, declarative statements about what exists or is true + 5. Separate distinct facts rather than combining them into a single summary + 6. Include specific details, numbers, limitations, or capabilities where available`, + }, + { + role: AIMessageRole.Developer, + content: `# 4. Current page + The content of the current page is:`, + attachments: [ + { + type: 'page' as const, + spaceId: currentSpace.id, + pageId: currentPage.id, + }, + ], + }, + ...(visitedPages && visitedPages.length > 0 + ? [ + { + role: AIMessageRole.Developer, + content: `# 5. Previous Pages and Learning Journey + The content across ${visitedPages.length} page(s) builds a knowledge framework. Use this to: + - Identify specific, concrete ways concepts interact (not just "work together") + - Show exact functional relationships between ideas (not vague "enhances") + - Highlight tangible capabilities that emerge from combined concepts + - Describe precise benefits that result from these connections + - Focus on what becomes possible when these concepts are combined + + The content of up to 5 most recent pages are included below:`, + }, + ...visitedPages.slice(0, 5).map(({ spaceId, pageId }, index) => ({ + role: AIMessageRole.Developer, + content: `## Previous Page ${index + 1}: ${pageId}`, + attachments: [ + { + type: 'page' as const, + spaceId, + pageId, + }, + ], + })), + ] + : []), + { + role: AIMessageRole.Developer, + content: `# 6. Guidelines for Fact Extraction + ALWAYS: + - Extract multiple distinct facts rather than a single summary + - Focus on specific, concrete details rather than general descriptions + - Include numbers, limitations, requirements, or specifications when available + - Prioritize facts that would be most useful to someone using the documentation + - Consider how facts on this page relate to previously visited pages + + NEVER: + - Use instructional language like "learn", "how to", "discover", etc. + - Include vague or generic statements that lack specific details + - Repeat the page title without adding informative value + - Combine multiple distinct facts into a single general statement`, + }, + { + role: AIMessageRole.Developer, + content: `## Big Picture Guidelines + For the big picture summary: + + ALWAYS: + - Use a natural, conversational tone a person would actually use + - Include concrete examples with specific benefits + - Balance being precise with sounding natural + - Use occasional contractions or slightly informal phrasing + - Write as if explaining to a colleague in a friendly way + + NEVER: + - Use empty relationship words like "enhances," "supports," or "integrates with" + - Write in an overly academic or technical style + - Use abstract phrases without concrete meaning + - Sound like marketing copy or documentation + - Lose specificity while trying to sound conversational + + POOR EXAMPLES TO AVOID: + ✗ "Markdown enhances content creation by integrating with collaboration features." + ✗ "API components support the documentation workflow through seamless integration." + ✗ "The robust search functionality facilitates efficient information retrieval." + + GOOD EXAMPLES TO FOLLOW: + ✓ "Markdown tables make API data easier to read, while code blocks let you test examples right in the docs." + ✓ "Webhooks save tons of time by automatically creating PDFs whenever content changes." + ✓ "Version control pins down exactly who changed what text, so you won't waste time on formatting debates."`, + }, + { + role: AIMessageRole.Developer, + content: `## Examples + + Page content: "Content blocks in GitBook include text, images, videos, code snippets, and more. Each block can be customized with specific settings. Text blocks support Markdown formatting and can include inline code. Images can be resized and have alt text added." + ✓ "Text blocks support Markdown formatting. Images can be resized and include alt text. Available block types include text, images, videos, and code snippets." + ✗ "GitBook offers various content blocks with customization options." + + Page content: "Change Requests allow teams to propose, review, and approve content changes before publishing. Each reviewer's approval is tracked separately. Changes are highlighted with color coding. Change Requests can be merged automatically or manually after approval." + ✓ "Reviewer approvals are tracked individually. Changes are color-coded for visibility. Merging can be automatic or manual after approval. Multiple reviewers can collaborate on a single Change Request." + ✗ "Change Requests provide a collaborative workflow for content changes." + + Page content: "API authentication requires an API key generated in account settings. Keys expire after 90 days by default. Rate limits are set to 1000 requests per hour. Keys can have read-only or read-write permissions." + ✓ "API keys expire after 90 days by default. Rate limits are capped at 1000 requests per hour. Keys can be configured with read-only or read-write permissions." + ✗ "API keys are required for authentication and have various settings."`, + }, + { + role: AIMessageRole.Developer, + content: `The current page is: "${currentPage.title}" (ID ${currentPage.id})`, + }, + { + role: AIMessageRole.User, + content: + 'What are the key facts on this page, and what have I learned across the documentation so far?', + }, + ], + } + ), + fetchServerActionSiteContext(baseContext), + ]); + + for await (const value of stream) { + const pageSummary = value.pageSummary; + const bigPicture = value.bigPicture; + + if (!pageSummary) continue; + + yield { + pageSummary, + bigPicture, + }; + } +} diff --git a/packages/gitbook/src/components/PageAside/PageAside.tsx b/packages/gitbook/src/components/PageAside/PageAside.tsx index 99cb7a2a25..a5bf845ddf 100644 --- a/packages/gitbook/src/components/PageAside/PageAside.tsx +++ b/packages/gitbook/src/components/PageAside/PageAside.tsx @@ -29,7 +29,7 @@ export function PageAside(props: { const { page, document, withPageFeedback, context } = props; const { customization, site, space } = context; - const useAdaptivePane = true; + const useAdaptivePane = customization.ai?.pageLinkSummaries.enabled; return (