Compare commits

...

15 Commits

Author SHA1 Message Date
Zeno Kapitein 24b6c077eb Add chat to summaries 2025-05-07 15:57:11 +02:00
Zeno Kapitein 51f59063ac Add chat 2025-05-07 15:05:31 +02:00
Zeno Kapitein c38d403f77 Layout fixes 2025-05-07 12:56:08 +02:00
Zeno Kapitein c3bde7d989 Cleanup 2025-05-06 21:04:31 +02:00
Zeno Kapitein 8a08b0f366 Merge branch 'main' into ai-page-summaries 2025-05-06 20:56:40 +02:00
Zeno Kapitein ed17c11715 Turn of tools (for now) 2025-05-06 20:55:48 +02:00
Zeno Kapitein 6ce3f4b17a Tweaks 2025-05-06 20:52:39 +02:00
Zeno Kapitein fb9c8f4d2c Second pass 2025-05-06 20:35:26 +02:00
Zeno Kapitein fbf6951c71 First pass 2025-05-06 16:55:06 +02:00
Zeno Kapitein d8cfb10974 Format 2025-05-01 10:49:32 +02:00
Zeno Kapitein 3cdba0ae8e Cleanup & format 2025-05-01 10:49:32 +02:00
Zeno Kapitein 83e02b621a Iteration 4 2025-05-01 10:49:32 +02:00
Zeno Kapitein cb2cc52c26 Third iteration 2025-05-01 10:49:32 +02:00
Zeno Kapitein 0e15229c52 Second iteration 2025-05-01 10:49:32 +02:00
Zeno Kapitein 8547867c6d Initial version 2025-05-01 10:49:32 +02:00
22 changed files with 945 additions and 202 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Adds AI sidebar with recommendations based on browsing behaviour
+2
View File
@@ -1394,6 +1394,8 @@ async function* streamAIResponse(
input: params.input,
output: params.output,
model: params.model,
tools: params.tools,
previousResponseId: params.previousResponseId,
});
for await (const event of res) {
@@ -189,5 +189,7 @@ export interface GitBookDataFetcher {
input: api.AIMessageInput[];
output: api.AIOutputFormat;
model: api.AIModel;
tools?: api.AIToolCapabilities;
previousResponseId?: string;
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
}
@@ -0,0 +1,235 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useVisitedPages } from '../Insights';
import { usePageContext } from '../PageContext';
import { Button } from '../primitives/Button';
import { useAdaptiveContext } from './AdaptiveContext';
import { streamPageQuestion } from './server-actions/streamPageQuestion';
import { streamPageSummary } from './server-actions/streamPageSummary';
interface ChatMessage {
type: 'question' | 'answer';
content: string;
}
type StreamData = { answer: string } | { newResponseId: string } | { toolUsage: boolean };
export function AIPageSummary() {
const { toggle, setLoading } = useAdaptiveContext();
const currentPage = usePageContext();
const visitedPages = useVisitedPages((state) => state.pages);
const visitedPagesRef = useRef(visitedPages);
const [summary, setSummary] = useState<{
keyFacts?: string;
bigPicture?: string;
}>({});
const [question, setQuestion] = useState('');
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [isAsking, setIsAsking] = useState(false);
const [responseId, setResponseId] = useState<string | null>(null);
const [showTypingIndicator, setShowTypingIndicator] = useState(false);
const handleSubmit = async () => {
if (!question.trim() || isAsking) return;
const currentQuestion = question;
setQuestion('');
setIsAsking(true);
setShowTypingIndicator(true);
// Add question to chat history
setChatHistory((prev) => [...prev, { type: 'question', content: currentQuestion }]);
try {
const stream = await streamPageQuestion(currentQuestion, responseId ?? '');
let currentAnswer = '';
for await (const data of stream as AsyncIterableIterator<StreamData>) {
if ('answer' in data && data.answer !== undefined) {
currentAnswer = data.answer;
setShowTypingIndicator(false);
// If the answer is empty, replace it with a generic error message
if (data.answer.trim() === '') {
currentAnswer =
'An answer could not be found for your question. You could try rephrasing it, or be more specific.';
}
// Update the last message in chat history with the streaming answer
setChatHistory((prev) => {
const newHistory = [...prev];
const lastMessage = newHistory[newHistory.length - 1];
if (lastMessage?.type === 'answer') {
lastMessage.content = currentAnswer;
} else {
newHistory.push({ type: 'answer', content: currentAnswer });
}
return newHistory;
});
} else if ('newResponseId' in data && data.newResponseId) {
setResponseId(data.newResponseId);
} else if ('toolUsage' in data) {
// Show typing indicator when tools are being used
setShowTypingIndicator(true);
}
}
} finally {
setIsAsking(false);
setShowTypingIndicator(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit();
}
};
useEffect(() => {
if (!summary.keyFacts) setLoading(true);
}, [summary.keyFacts, setLoading]);
useEffect(() => {
if (!visitedPages?.length) return;
// Skip if the visited pages haven't changed
if (JSON.stringify(visitedPagesRef.current) === JSON.stringify(visitedPages)) return;
visitedPagesRef.current = visitedPages;
let canceled = false;
setLoading(true);
(async () => {
const stream = await streamPageSummary({
currentPage: {
id: currentPage.pageId,
title: currentPage.title,
},
currentSpace: {
id: currentPage.spaceId,
title: currentPage.spaceTitle,
},
visitedPages: visitedPages,
});
for await (const data of stream) {
if (canceled) return;
if ('responseId' in data && data.responseId !== undefined) {
setResponseId(data.responseId);
}
setSummary((prev) => ({
keyFacts: data.keyFacts ?? prev.keyFacts,
bigPicture: data.bigPicture ?? prev.bigPicture,
}));
}
})().finally(() => {
setLoading(false);
});
return () => {
canceled = true;
};
}, [currentPage, visitedPages, setLoading]);
const shimmerBlocks = [20, 35, 25, 10, 45, 30, 30, 35, 25, 10, 40, 30]; // Widths in percentages
return (
toggle.open && (
<div className="flex min-w-64 animate-fadeIn flex-col gap-4">
{summary.keyFacts ? (
<div>
<h5 className="mb-0.5 font-semibold text-tint-subtle text-xs uppercase">
Key facts
</h5>
{summary.keyFacts}
</div>
) : (
<div className="flex w-full flex-wrap gap-2">
{shimmerBlocks.map((width, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: No other distinguishing feature available
key={index}
className="h-4 animate-pulse rounded bg-tint-active"
style={{
width: `${width}%`,
animationDelay: `${index * 0.1}s`,
}}
/>
))}
</div>
)}
{visitedPages.length > 1 && summary?.bigPicture ? (
<div>
<h5 className="mb-0.5 font-semibold text-tint-subtle text-xs uppercase">
Big Picture
</h5>
{summary?.bigPicture}
</div>
) : null}
{chatHistory.length > 0 && (
<div className="flex flex-col gap-3">
{chatHistory.map((message, index) => (
<div
key={index}
className={`flex ${message.type === 'question' ? 'animate-[present_300ms_ease-in-out_both] justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[90%] rounded-lg px-4 py-2 ${
message.type === 'question'
? 'bg-primary-solid text-contrast-primary-solid'
: 'bg-tint-active'
}`}
>
{message.content}
</div>
</div>
))}
{showTypingIndicator && (
<div className="flex w-full max-w-[90%] animate-[present_300ms_200ms_ease-in-out_both] flex-wrap items-center gap-2 rounded-lg bg-tint-active px-4 py-3">
{shimmerBlocks.slice(0, 5).map((width, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: No other distinguishing feature available
key={index}
className="h-3 animate-pulse rounded bg-tint-11/4"
style={{
width: `${width}%`,
animationDelay: `${index * 0.1}s`,
}}
/>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
<input
type="text"
className={`w-full rounded-md border border-tint-subtle px-3 py-2 transition-all duration-300 ${!responseId && 'scale-95'}`}
placeholder="Ask about this page"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isAsking || !responseId}
/>
<Button
iconOnly
icon="send"
variant="blank"
disabled={!responseId}
onClick={handleSubmit}
/>
</div>
</div>
)
);
}
@@ -0,0 +1,71 @@
'use client';
import React from 'react';
type AdaptiveContextType = {
loading: boolean;
setLoading: (loading: boolean) => void;
toggle: {
open: boolean;
manual: boolean;
};
setToggle: (toggle: { open: boolean; manual: boolean }) => void;
};
export const AdaptiveContext = React.createContext<AdaptiveContextType | null>(null);
/**
* Client side context provider to pass information about the current page.
*/
export function AdaptiveContextProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = React.useState(true);
// Start with a default state that works for SSR
const [toggle, setToggle] = React.useState({
open: false, // Default to open for SSR
manual: false,
});
// Update the toggle state on the client side only
React.useEffect(() => {
// Check for mobile only on the client
const handleResize = () => {
if (!toggle.manual) {
const isMobile = window.innerWidth < 1280;
setToggle((prev) => ({
...prev,
open: !isMobile,
}));
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [toggle.manual]);
React.useEffect(() => {
if (toggle.open) {
document.body.classList.add('adaptive-pane');
} else {
document.body.classList.remove('adaptive-pane');
}
}, [toggle.open]);
return (
<AdaptiveContext.Provider value={{ loading, setLoading, toggle, setToggle }}>
{children}
</AdaptiveContext.Provider>
);
}
/**
* Hook to use the adaptive context.
*/
export function useAdaptiveContext() {
const context = React.useContext(AdaptiveContext);
if (!context) {
throw new Error('useAdaptiveContext must be used within a AdaptiveContextProvider');
}
return context;
}
@@ -0,0 +1,21 @@
'use client';
import { tcls } from '@/lib/tailwind';
import { AIPageSummary } from './AIPageSummary';
import { useAdaptiveContext } from './AdaptiveContext';
import { AdaptivePaneHeader } from './AdaptivePaneHeader';
export function AdaptivePane() {
const { toggle } = useAdaptiveContext();
return (
<div
className={tcls(
'flex shrink-0 flex-col gap-4 overflow-hidden rounded-md straight-corners:rounded-none bg-tint-subtle ring-1 ring-tint-subtle ring-inset transition-all duration-300',
toggle.open ? 'max-h px-4 py-4 xl:w-72' : 'px-4 py-3 xl:w-56'
)}
>
<AdaptivePaneHeader />
<AIPageSummary />
</div>
);
}
@@ -0,0 +1,46 @@
'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, toggle, setToggle } = useAdaptiveContext();
return (
<div className="flex flex-row items-center gap-3 rounded-md straight-corners:rounded-none transition-all duration-500">
<div className="flex grow flex-col">
<h4 className="flex items-center gap-1.5 font-semibold ">
<Loading className="size-4 text-tint-subtle" busy={loading} />
For you
</h4>
<AnimatePresence initial={false} mode="wait">
<motion.h5
key={loading ? 'loading' : 'loaded'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="text-tint-subtle text-xs"
>
{loading ? 'Basing on your context...' : 'Based on your context'}
</motion.h5>
</AnimatePresence>
</div>
<Button
variant="blank"
className={tcls('px-2 *:transition-transform', !toggle.open && '*:-rotate-45')}
iconOnly
label="Close"
icon="close"
onClick={() =>
setToggle({
open: !toggle.open,
manual: true,
})
}
/>
</div>
);
}
@@ -1 +1,4 @@
export * from './AIPageLinkSummary';
export * from './AIPageSummary';
export * from './AdaptiveContext';
export * from './AdaptivePane';
@@ -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';
@@ -46,28 +51,33 @@ export async function streamGenerateObject<T>(
{
schema,
messages,
previousResponseId,
model = AIModel.Fast,
tools = {},
}: {
schema: z.ZodSchema<T>;
messages: AIMessageInput[];
model?: AIModel;
previousResponseId?: string;
tools?: AIToolCapabilities;
}
) {
const rawStream = context.dataFetcher.streamAIResponse({
organizationId,
siteId,
previousResponseId,
input: messages,
output: {
type: 'object',
schema: zodToJsonSchema(schema),
},
tools,
model,
});
let json = '';
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
if (event.type === 'response_object') {
if (event.type === 'response_object' && event.jsonChunk) {
json += event.jsonChunk;
const parsed = partialJson.parse(json, partialJson.ALL);
@@ -1 +1,2 @@
export * from './streamLinkPageSummary';
export * from './streamPageSummary';
@@ -0,0 +1,83 @@
'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* streamPageQuestion(question: string, responseId: string) {
const baseContext = isV2() ? await getServerActionBaseContext() : await getV1BaseContext();
const siteURLData = await getSiteURLDataFromMiddleware();
const [{ stream, response }] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
answer: z.string().describe('The answer to the question'),
}),
previousResponseId: responseId,
tools: {
search: true,
getPageContent: true,
getPages: true,
},
messages: [
{
role: AIMessageRole.Developer,
content:
'The user is asking a question about the page. Use your knowledge of the page and the context to answer the question. Be succinct in your answers, do not repeat information already in the key facts or big picture.',
},
{
role: AIMessageRole.Developer,
content:
'Important: NEVER respond with anything except the answer to the question. Do not respond with anything else. If the input is not a question about the documentation or you cannot answer the question using the context provided, respond with an empty string.',
},
{
role: AIMessageRole.Developer,
content:
'Use the tools available to you to find the answers (read page content, etc).',
},
{
role: AIMessageRole.User,
content: question,
},
],
}
),
fetchServerActionSiteContext(baseContext),
]);
// Get the responseId asynchronously in the background
let newResponseId: string | null = null;
const responseIdPromise = response
.then((r) => {
newResponseId = r.responseId;
})
.catch((error) => {
console.error('Error getting responseId:', error);
});
// Start processing the stream immediately
for await (const value of stream) {
// Always yield the answer, even if it's an empty string
if ('answer' in value) {
yield {
answer: value.answer,
};
}
}
// Wait for the responseId to be available and yield one final time
await responseIdPromise;
yield { newResponseId };
}
@@ -0,0 +1,228 @@
'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, response }] = await Promise.all([
streamGenerateObject(
baseContext,
{
organizationId: siteURLData.organization,
siteId: siteURLData.site,
},
{
schema: z.object({
keyFacts: 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(),
}),
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 space and page
The user is currently reading the page titled "${currentPage.title}" (ID ${currentPage.id}) in the space titled "${currentSpace.title}" (ID ${currentSpace.id}).
Use these identifiers for tool calls.
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:
- ALWAYS extract multiple distinct facts rather than a single summary
- ALWAYS focus on specific, concrete details rather than general descriptions
- ALWAYS include numbers, limitations, requirements, or specifications when available
- ALWAYS prioritize facts that would be most useful to someone using the documentation
- ALWAYS consider how facts on this page relate to previously visited pages
NEVER:
- NEVER use instructional language like "learn", "how to", "discover", etc.
- NEVER include vague or generic statements that lack specific details.
- NEVER repeat the page title without adding informative value.
- NEVER combine multiple distinct facts into a single general statement.
- NEVER use numbered lists.`,
},
{
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: `# 7. Guidelines for Big Picture
For the big picture summary:
ALWAYS:
- ALWAYS highlight practical patterns and workflows that emerge when combining these concepts.
- ALWAYS focus on real capabilities that come from understanding multiple features together.
- ALWAYS use specific examples that show the value of combining these ideas.
- ALWAYS keep the language simple, direct and conversational without corporate jargon.
- ALWAYS use short sentences with a single clause and no commas.
NEVER:
- NEVER use corporate jargon like "seamless", "ensures", "integrates", etc.
- NEVER use complex sentences with multiple clauses.
- NEVER use passive voice.
- NEVER state the same fact twice.
- NEVER repeat the page title without adding informative value.`,
},
{
role: AIMessageRole.Developer,
content: `## Big Picture Examples
POOR "BIG PICTURE" EXAMPLES TO AVOID:
✗ "GitBook combines content creation, collaboration, and integrations, building on your understanding of identifiers and paginated results for seamless documentation management."
✗ "The platform's robust features for content organization, versioning, and access control work together to create a powerful documentation ecosystem."
✗ "By leveraging GitBook's content blocks, permissions system, and API capabilities, you can build comprehensive documentation solutions."
GOOD "BIG PICTURE" EXAMPLES TO FOLLOW:
✓ "Combining Markdown tables with webhook notifications means your API docs stay up-to-date automatically. When you update a parameter, the PDF version refreshes too."
✓ "Content blocks and version history together solve the biggest docs headache. You can experiment with different layouts while keeping a clean record of what changed and why."
✓ "The real power comes from linking custom domains with content permissions. Your sales team gets branded docs while your developers see the technical details on the same site."
✓ "With spaces, webhooks, and custom metadata working together, you're not just making docs. You're building a knowledge system that responds to how your team actually works."`,
},
{
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),
]);
// Get the responseId asynchronously in the background
let responseId: string | null = null;
const responseIdPromise = response
.then((r) => {
responseId = r.responseId;
})
.catch((error) => {
console.error('Error getting responseId:', error);
});
// Start processing the stream immediately
for await (const value of stream) {
const keyFacts = value.keyFacts;
const bigPicture = value.bigPicture;
if (!keyFacts) continue;
yield {
keyFacts,
bigPicture,
};
}
// Wait for the responseId to be available and yield one final time
await responseIdPromise;
yield { responseId };
}
@@ -106,12 +106,16 @@ export function Header(props: { context: GitBookSiteContext; withTopHeader?: boo
'lg:max-w-lg',
'lg:ml-[max(calc((100%-18rem-48rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - margin (3rem)
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
'adaptive-pane:xl:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),1.5rem)]',
'page-no-toc:lg:ml-[max(calc((100%-18rem-48rem-18rem-3rem)/2),0rem)]',
'page-full-width:lg:ml-[max(calc((100%-18rem-103rem-3rem)/2),1.5rem)]',
'page-full-width:2xl:ml-[max(calc((100%-18rem-96rem-14rem+3rem)/2),1.5rem)]',
'[body.adaptive-pane:has(.page-full-width)_&]:2xl:ml-[max(calc((100%-18rem-96rem-18rem+3rem)/2),1.5rem)]',
'md:mr-auto',
'order-last',
'md:order-[unset]',
'transition-[margin-left]',
'duration-300',
]
: ['order-last']
)}
@@ -0,0 +1,106 @@
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 urlJoin from 'url-join';
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 (
<div
className={tcls(
'flex',
'flex-col',
'gap-2',
'sidebar-list-default:px-3',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle'
)}
>
<Icon icon="file-pdf" className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
);
}
function getGitSyncName(space: Space): string {
if (space.gitSync?.installationProvider === 'github') {
return 'GitHub';
}
if (space.gitSync?.installationProvider === 'gitlab') {
return 'GitLab';
}
return 'Git';
}
@@ -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.
@@ -33,33 +28,34 @@ export function PageAside(props: {
}) {
const { page, document, withPageFeedback, 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()}`
);
const useAdaptivePane = customization.ai?.pageLinkSummaries.enabled;
return (
<aside
className={tcls(
'group/aside',
'hidden',
'flex',
// 'hidden',
'xl:flex',
// 'page-no-toc:lg:flex',
'flex-col',
'basis-56',
// 'page-no-toc:basis-40',
// 'page-no-toc:xl:basis-56',
'xl:basis-56',
'grow-0',
'shrink-0',
'break-anywhere', // To prevent long words in headings from breaking the layout
'text-tint',
'contrast-more:text-tint-strong',
'sticky',
'text-sm',
'xl:sticky',
'lg:px-12',
'xl:px-0',
'mx-auto',
'xl:mx-0',
'w-full',
'max-w-3xl',
// Without header
'lg:top-0',
'lg:max-h-screen',
@@ -91,144 +87,22 @@ export function PageAside(props: {
'page-api-block:p-2'
)}
>
{page.layout.outline ? (
<>
<div
className={tcls(
'hidden',
'page-api-block:xl:max-2xl:flex',
'text-xs',
'tracking-wide',
'font-semibold',
'uppercase',
'flex-row',
'items-center',
'gap-2'
)}
>
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
<Icon
icon="chevron-down"
className={tcls(
'size-3',
'opacity-6',
'ml-auto',
'page-api-block:xl:max-2xl:group-hover/aside:hidden'
)}
<div className="lg:top:0 sticky flex grow flex-col gap-6 overflow-y-auto overflow-x-visible border-none pt-8 *:border-tint-subtle site-header-sections:lg:top-[6.75rem] site-header:lg:top-16 xl:pb-8 [&>*:not(:first-child)]:border-t [&>*:not(:first-child)]:pt-6">
{useAdaptivePane ? <AdaptivePane /> : null}
{page.layout.outline ? (
<div className="hidden flex-col gap-6 xl:flex">
<PageOutline document={document} context={context} />
<PageActions
page={page}
context={context}
withPageFeedback={withPageFeedback}
/>
</div>
<div
className={tcls(
'overflow-y-auto',
'overflow-x-visible',
'flex',
'flex-col',
'shrink',
'pb-12',
'sticky',
'lg:top:0',
'site-header:lg:top-16',
'site-header-sections:lg:top-[6.75rem]',
'gap-6',
'pt-8',
'page-api-block:xl:max-2xl:py-0',
// Hide it for api page, until hovered
'page-api-block:xl:max-2xl:hidden',
'page-api-block:xl:max-2xl:group-hover/aside:flex'
)}
>
{document ? (
<React.Suspense fallback={null}>
<PageAsideSections document={document} context={context} />
</React.Suspense>
) : null}
<div
className={tcls(
'flex',
'flex-col',
'gap-3',
'sidebar-list-default:px-3',
'border-t',
'first:border-none',
'border-tint-subtle',
'py-4',
'first:pt-0',
'page-api-block:xl:max-2xl:px-3',
'empty:hidden'
)}
>
{withPageFeedback ? (
<React.Suspense fallback={null}>
<PageFeedbackForm pageId={page.id} className={tcls('mt-2')} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon={
space.gitSync.installationProvider === 'gitlab'
? 'gitlab'
: 'github'
}
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={pdfHref}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'hover:text-tint-strong',
'links-accent:hover:underline',
'links-accent:hover:underline-offset-4',
'links-accent:hover:decoration-[3px]',
'links-accent:hover:decoration-primary-subtle',
'py-2'
)}
>
<Icon
icon="file-pdf"
className={tcls('size-4', 'mr-1.5')}
/>
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
</div>
</>
) : null}
) : null}
</div>
<div
className={tcls(
'sticky bottom-0 z-10 mt-auto flex flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'sticky bottom-0 z-10 mt-auto hidden flex-col bg-tint-base theme-gradient-tint:bg-gradient-tint theme-gradient:bg-gradient-primary theme-muted:bg-tint-subtle pb-4 xl:flex page-api-block:xl:max-2xl:hidden page-api-block:xl:max-2xl:pb-0 page-api-block:xl:max-2xl:group-hover/aside:flex [html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
'page-api-block:xl:max-2xl:bg-transparent'
)}
>
@@ -254,22 +128,3 @@ export function PageAside(props: {
</aside>
);
}
async function PageAsideSections(props: { document: JSONDocument; context: GitBookSiteContext }) {
const { document, context } = props;
const sections = await getDocumentSections(context, document);
return sections.length > 1 ? <ScrollSectionsList sections={sections} /> : null;
}
function getGitSyncName(space: Space): string {
if (space.gitSync?.installationProvider === 'github') {
return 'GitHub';
}
if (space.gitSync?.installationProvider === 'gitlab') {
return 'GitLab';
}
return 'Git';
}
@@ -0,0 +1,45 @@
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 async function PageOutline(props: {
document: JSONDocument | null;
context: GitBookSiteContext;
}) {
const { document, context } = props;
const { customization } = context;
const language = getSpaceLanguage(customization);
if (!document) return;
const sections = await getDocumentSections(context, document);
return document && sections.length > 1 ? (
<div>
<div className="mb-1 flex flex-row items-center gap-2 font-semibold text-xs uppercase tracking-wide">
<Icon icon="block-quote" className={tcls('size-3')} />
{t(language, 'on_this_page')}
</div>
<div
className={tcls(
'flex',
'flex-col'
// 'page-api-block:xl:max-2xl:py-0',
// // Hide it for api page, until hovered
// 'page-api-block:xl:max-2xl:hidden',
// 'page-api-block:xl:max-2xl:group-hover/aside:flex'
)}
>
<React.Suspense fallback={null}>
<ScrollSectionsList sections={sections} />
</React.Suspense>
</div>
</div>
) : null;
}
@@ -4,8 +4,9 @@ import React from 'react';
export type PageContextType = {
pageId: string;
spaceId: string;
title: string;
spaceId: string;
spaceTitle: string;
};
export const PageContext = React.createContext<PageContextType | null>(null);
@@ -14,9 +15,12 @@ export const PageContext = React.createContext<PageContextType | null>(null);
* Client side context provider to pass information about the current page.
*/
export function PageContextProvider(props: PageContextType & { children: React.ReactNode }) {
const { pageId, spaceId, title, children } = props;
const { pageId, spaceId, title, spaceTitle, children } = props;
const value = React.useMemo(() => ({ pageId, spaceId, title }), [pageId, spaceId, title]);
const value = React.useMemo(
() => ({ pageId, spaceId, spaceTitle, title }),
[pageId, spaceId, spaceTitle, title]
);
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
}
@@ -34,6 +34,7 @@ import { ClientContexts } from './ClientContexts';
import '@gitbook/icons/style.css';
import './globals.css';
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
import { AdaptiveContextProvider } from '../Adaptive/AdaptiveContext';
import { AnnouncementDismissedScript } from '../Announcement';
/**
@@ -175,7 +176,9 @@ export async function CustomizationRootLayout(props: {
: null) || IconStyle.Regular
}
>
<ClientContexts language={language}>{children}</ClientContexts>
<ClientContexts language={language}>
<AdaptiveContextProvider>{children}</AdaptiveContextProvider>
</ClientContexts>
</IconsProvider>
</body>
</html>
@@ -65,12 +65,17 @@ export async function SitePage(props: SitePageProps) {
const document = await getPageDocument(context.dataFetcher, context.space, page);
return (
<PageContextProvider pageId={page.id} spaceId={context.space.id} title={page.title}>
<PageContextProvider
pageId={page.id}
spaceId={context.space.id}
spaceTitle={context.space.title}
title={page.title}
>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={context} />
) : null}
{/* We use a flex row reverse to render the aside first because the page is streamed. */}
<div className="flex grow flex-row-reverse justify-end">
<div className="flex grow flex-col xl:flex-row-reverse xl:justify-end">
<PageAside
page={page}
document={document}
@@ -14,6 +14,7 @@ type ButtonProps = {
iconOnly?: boolean;
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
disabled?: boolean;
label?: string;
} & LinkInsightsProps &
HTMLAttributes<HTMLElement>;
@@ -33,10 +34,12 @@ const variantClasses = {
'ring-0',
'shadow-none',
'hover:bg-primary-hover',
'disabled:hover:bg-transparent',
'hover:text-primary',
'hover:scale-1',
'hover:shadow-none',
'contrast-more:bg-tint-subtle',
'disabled:hover:shadow-none',
],
secondary: [
'bg-tint',
@@ -57,6 +60,7 @@ export function Button({
label,
icon,
iconOnly = false,
disabled = false,
...rest
}: ButtonProps & { target?: HTMLAttributeAnchorTarget }) {
const sizes = {
@@ -94,6 +98,10 @@ export function Button({
'active:scale-100',
'transition-all',
'disabled:opacity-5',
'disabled:cursor-not-allowed',
'disabled:hover:shadow-none',
'grow-0',
'shrink-0',
'truncate',
@@ -119,7 +127,13 @@ export function Button({
}
return (
<button type="button" className={domClassName} aria-label={label} {...rest}>
<button
type="button"
className={domClassName}
aria-label={label}
disabled={disabled}
{...rest}
>
{icon ? <Icon icon={icon} className={tcls('size-[1em]')} /> : null}
{iconOnly ? null : label}
</button>
@@ -1,7 +1,5 @@
import type { SVGProps } from 'react';
import { tcls } from '@/lib/tailwind';
export const Loading = ({
busy = true,
...props
@@ -13,29 +11,26 @@ export const Loading = ({
preserveAspectRatio="xMaxYMid meet"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-busy
aria-busy={busy}
{...props}
>
<title>{busy ? 'Loading' : 'Loaded'}</title>
<path
className={tcls(
busy
? 'animate-[pathLoading_2s_ease_infinite_forwards]'
: 'animate-[pathLoading_2s_ease_forwards]'
)}
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
stroke="currentColor"
pathLength="100"
strokeOpacity={busy ? 0.24 : 1}
className="transition-[stroke-opacity] duration-500"
fill="none"
strokeWidth="11"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="animate-[pathLoading_2s_ease_infinite_forwards] transition-opacity delay-500 duration-500"
d="M6 59.5V56.291C6 45.8865 11.5194 36.263 20.5 31.0091V31.0091L60.9857 7.32407C63.4452 5.88525 66.4843 5.86317 68.9643 7.26611L116 33.8734L70.4183 60.2148C67.9468 61.6431 64.9014 61.6462 62.4269 60.223L29.9772 41.5592C19.3106 35.4242 6 43.1236 6 55.4288V64.8776C6 73.4486 10.5708 81.3691 17.9918 85.6575L54.59 106.807C62.0198 111.1 71.1766 111.1 78.6064 106.807L116.364 84.9874C120.074 82.8432 122.36 78.883 122.36 74.5975V59.2647C122.36 57.7248 120.692 56.7626 119.359 57.5331L72.6023 84.5529C68.8874 86.6996 64.309 86.6996 60.5941 84.5529L26 64.5617"
stroke="currentColor"
pathLength="100"
strokeOpacity={busy ? 0.24 : 1}
className="transition-opacity duration-1000"
fill="none"
strokeWidth="11"
strokeLinecap="round"
+13 -8
View File
@@ -296,14 +296,14 @@ const config: Config = {
},
animation: {
present: 'present 200ms cubic-bezier(0.25, 1, 0.5, 1) both',
scaleIn: 'scaleIn 200ms ease',
scaleOut: 'scaleOut 200ms ease',
fadeIn: 'fadeIn 200ms ease forwards',
fadeOut: 'fadeOut 200ms ease forwards',
enterFromLeft: 'enterFromLeft 250ms ease',
enterFromRight: 'enterFromRight 250ms ease',
exitToLeft: 'exitToLeft 250ms ease',
exitToRight: 'exitToRight 250ms ease',
scaleIn: 'scaleIn 200ms ease both',
scaleOut: 'scaleOut 200ms ease both',
fadeIn: 'fadeIn 200ms ease both',
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: {
@@ -465,6 +465,11 @@ const config: Config = {
'body:has(.page-no-toc):has(#site-header:not(.mobile-only) #variants) &',
]);
/**
* Variant when the adaptive pane is open.
*/
addVariant('adaptive-pane', 'body.adaptive-pane &');
const customisationVariants = {
// Sidebar styles
sidebar: ['sidebar-default', 'sidebar-filled'],