diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 50cf96a0d..71ffa1045 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 20e495656..0af1e9e36 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 178a0ba77..a2b151938 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 000000000..389bf1d20 --- /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 000000000..2327ace49 --- /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 a1396987d..fdc642c15 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 664e869e2..c42737304 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 000000000..d0a98411d --- /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 000000000..0a2db6005 --- /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 a51410cbc..05f8295c7 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 000000000..1fb535647 --- /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; +}