Compare commits

...

12 Commits

Author SHA1 Message Date
Nolann Biron d25cc924e4 Gate AIChatProvider 2026-07-16 10:58:03 +02:00
Nolann Biron 68e50d68e5 revert link + re-add optimizePackage 2026-07-16 10:13:07 +02:00
Nolann Biron 46b43c4372 prefetch TOC on hover 2026-07-16 09:58:10 +02:00
Nolann Biron f8fd5e2bb4 revert some changes 2026-07-16 09:30:39 +02:00
Nolann Biron 9ab6cfb878 quick review 2026-07-16 00:16:17 +02:00
Nolann Biron c9a637e0d9 fix openapi styling 2026-07-15 23:03:23 +02:00
Nolann Biron c16ab0a2ce fixes 2026-07-15 22:27:53 +02:00
Nolann Biron fe20392d21 changeset 2026-07-15 21:15:42 +02:00
Nolann Biron 8a81d77151 Trim client bundle via optimizePackageImports and a modern browserslist
Enable Next.js optimizePackageImports for motion, @gitbook/icons and react-aria so barrel imports tree-shake to only the used entrypoints. Raise the browserslist target to Chrome 93 / Edge 93 / Firefox 92 / Safari 15.4 so modern syntax (Array.prototype.at, Object.hasOwn, etc.) is no longer down-compiled and polyfilled across the whole client bundle.
2026-07-15 21:15:23 +02:00
Nolann Biron 55ac341cc9 Lazy-load OpenAPI and ContentKit stylesheets
The OpenAPI/Scalar stylesheet (~148KB raw, unpurgeable) and the ContentKit stylesheet were statically imported by the block dispatcher, so they shipped in every page's render-blocking CSS regardless of whether the page used those blocks. They now load on demand only when an OpenAPI or integration block renders, mirroring the existing KaTeX CSS pattern.
2026-07-15 21:15:23 +02:00
Nolann Biron 2f5b0a7f8a Lazy-load the admin toolbar for anonymous visitors
The admin toolbar (and the Framer Motion it pulls in) shipped to every visitor and only decided client-side whether to render, so anonymous readers downloaded and executed it just to render nothing. It is now split behind a lightweight gate that dynamically imports the full toolbar only for editor/change-request/revision views and authenticated organization members.
2026-07-15 21:15:23 +02:00
Nolann Biron 99f751c060 Defer search index loading until search is opened
Every page previously preloaded and fetched the whole-site search index (~441KB) plus FlexSearch on load, even when search was never used. The index and FlexSearch now load on first search intent (open/query), the results UI is code-split, and the index payload dedupes repeated breadcrumbs into a shared table (version 2, backward-compatible with version 1).
2026-07-15 21:15:23 +02:00
26 changed files with 1249 additions and 1048 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Reduce the JavaScript and CSS loaded on published site pages: the search index and its UI now load only when search is opened, and the admin toolbar and OpenAPI/ContentKit styles are no longer shipped to every visitor.
+10
View File
@@ -37,6 +37,16 @@ const nextConfig = {
optimisticClientCache: false,
// Disable splitting the RSC in like 5 chunks
prefetchInlining: true,
// Tree-shake barrel imports from these packages so only the used entrypoints ship
// in the client bundle (notably `motion`, which is otherwise pulled in wholesale).
optimizePackageImports: [
'motion',
'@gitbook/icons',
'react-aria',
'react-aria-components',
'react-stately',
],
},
env: {
+1 -3
View File
@@ -138,9 +138,7 @@
"e2e-browserless": "bun test ./tests/",
"typecheck": "tsc --noEmit"
},
"browserslist": [
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
],
"browserslist": ["chrome >= 93, edge >= 93, firefox >= 92, safari >= 15.4, not dead"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
@@ -0,0 +1,616 @@
'use client';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AIChatReference } from './references';
import { serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import {
AIChatControllerContext,
type AIChatEvent,
getDefaultAIChatMessageActivity,
globalAIChatState as globalState,
updateAIChatMessageActivity,
} from './useAIChat';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
'type'
>;
// The assistant's tools and controls pull in zod (~270KB chunk); load them on demand so the
// provider itself stays light and the chunk is only fetched on AI-enabled sites.
function importAITooling() {
return Promise.all([import('./tools'), import('./controls/ConfirmControl')]);
}
function notify(
listeners: AIChatEventListener[] | undefined,
input: Omit<AIChatEvent, 'type'>
): void {
if (!listeners) return;
// Defer event listeners to next tick so React can process state updates first
setTimeout(() => {
listeners.forEach((listener) => listener(input));
}, 0);
}
/**
* Provide the controller to interact with the AI chat.
*/
export function AIChatProvider(props: {
renderMessageOptions?: RenderAIMessageOptions;
children: React.ReactNode;
}) {
const { renderMessageOptions, children } = props;
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
// identity, so it can be referenced directly from the streaming callback.
const navigateToPageTool = useNavigateToPageTool();
// Warm the tools/controls chunk in the background so the first message doesn't pay its
// download latency. Only runs on AI-enabled sites since the provider is gated.
React.useEffect(() => {
void importAITooling();
}, []);
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
globalState.setState((state) => ({ ...state, opened: false }));
// Clear ask parameter but keep other search state
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]);
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
// Stream a message with the AI backend
const streamResponse = React.useCallback(
async (input: {
/** Text message to send to the AI backend */
message?: string;
/** User-typed prompt; compared against state.query to abort stale streams */
userQuery?: string;
/** Tool call to send to the AI backend */
toolCall?: AIToolCallResult;
}) => {
globalState.setState((state) => {
return {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
...state.messages,
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
});
// A stream becomes stale once a newer turn (or a clear) has replaced its
// query. Because `responding` clears on `response_finish` — before follow-up
// suggestions finish streaming — the user can start a new turn while this one
// is still wrapping up. A stale stream must not mutate the shared
// loading/responding state, which now belongs to the active turn; otherwise
// it would make the UI look idle mid-response. (`userQuery` is only set for
// user-initiated turns, not tool-call continuations.)
const isSuperseded = () =>
!!input.userQuery && globalState.getState().query !== input.userQuery;
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const [{ getTools }] = await importAITooling();
const tools = getTools([navigateToPageTool]);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
try {
const result = await toolDef.execute(event.toolCall.input);
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
} catch (error) {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
error: error instanceof Error ? error.message : 'Unknown error',
},
summary: {
icon: 'bomb',
text: 'An error occurred while executing the tool',
},
},
});
}
};
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const [{ getTools }, { ConfirmControlDef, ConfirmControlOutputSchema }] =
await importAITooling();
const tools = getTools([navigateToPageTool]);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: await getInsightsSession(),
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
},
});
// Process streaming response
for await (const data of stream) {
if (!data) continue;
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
const event = data.event;
switch (event.type) {
case 'response_finish': {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
responding: false,
error: false,
}));
break;
}
case 'response_followup_suggestion': {
globalState.setState((state) => ({
...state,
followUpSuggestions: [
...state.followUpSuggestions,
...event.suggestions,
],
}));
break;
}
case 'response_tool_call_pending': {
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
language,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
icon: confirmation.icon,
},
language,
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
}));
break;
}
toolToExecute = event;
break;
}
}
// Update the assistant message with streamed content
globalState.setState((state) => ({
...state,
messages: [
...state.messages.slice(0, -1),
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
}
// If a newer turn replaced this one while we were finishing (e.g.
// streaming follow-up suggestions after `response_finish`), abandon this
// stale stream without executing leftover tools or clearing the shared
// loading/responding state, which now belongs to the active turn.
if (isSuperseded()) {
return;
}
// Execute the tool call if it doesn't require confirmation.
// When a tool call (or control) keeps the turn going, `loading`
// stays true: either the recursive `streamResponse` will clear it
// when its stream settles, or it is cleared below once the loop ends
// (e.g. while waiting on a user confirmation control).
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: false,
}));
// Turn settled: send the next queued follow-up (oldest first). Held back while a
// control is pending, since posting would throw; it flushes after that resolves.
const { queuedMessages, control: activeControl } = globalState.getState();
const [next, ...rest] = queuedMessages;
if (next !== undefined && !activeControl) {
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
postMessageRef.current?.({ message: next });
}
}
} catch (error) {
console.error('Error streaming AI response', error);
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
messageContextRef.current,
renderMessageOptions?.withLinkPreviews,
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
]
);
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control, references, responding } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
if (responding) {
globalState.setState((state) => ({
...state,
queuedMessages: [...state.queuedMessages, input.message],
}));
return;
}
const wireMessage = `${serializeReferences(references)}${input.message}`;
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message && references.length === 0) {
// Return early if the message is the same as the previous message
// (unless new references are staged, which change the payload)
globalState.setState((state) => ({
...state,
opened: true,
}));
return;
}
trackEvent({ type: 'ask_question', query: input.message });
// Add user message and placeholder for AI response
globalState.setState((state) => {
return {
...state,
messages: [
...state.messages,
{
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
postMessageRef.current = onPostMessage;
// Remove a follow-up queued while the assistant is still answering (the × on the affordance).
const onCancelQueuedMessage = React.useCallback((index: number) => {
globalState.setState((state) =>
index < 0 || index >= state.queuedMessages.length
? state
: {
...state,
queuedMessages: state.queuedMessages.filter((_, i) => i !== index),
}
);
}, []);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
followUpSuggestions: [],
control: null,
responseId: null,
error: false,
initialQuery: null,
references: [],
queuedMessages: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
const onAddReference = React.useCallback((ref: AIChatReference) => {
globalState.setState((state) => {
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
return state;
}
return {
...state,
references: [...state.references, ref],
};
});
return ref.id;
}, []);
const onRemoveReference = React.useCallback((id: string) => {
globalState.setState((state) => {
if (!state.references.some((ref) => ref.id === id)) {
return state;
}
return {
...state,
references: state.references.filter((ref) => ref.id !== id),
};
});
}, []);
const onClearReferences = React.useCallback(() => {
globalState.setState((state) => {
if (state.references.length === 0) {
return state;
}
return { ...state, references: [] };
});
}, []);
const onFocus = React.useCallback(() => {
notify(eventsRef.current.get('focus'), {});
}, []);
const onSetDraft = React.useCallback((draft: string) => {
globalState.setState({ draft });
}, []);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => {
const listeners = eventsRef.current.get(event) || [];
listeners.push(listener as AIChatEventListener);
eventsRef.current.set(event, listeners);
return () => {
const currentListeners = eventsRef.current.get(event) || [];
eventsRef.current.set(
event,
currentListeners.filter((l) => l !== listener)
);
};
},
[]
);
const controller = React.useMemo(() => {
return {
open: onOpen,
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
setDraft: onSetDraft,
cancelQueuedMessage: onCancelQueuedMessage,
on: onEvent,
};
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onSetDraft,
onCancelQueuedMessage,
onEvent,
]);
return (
<AIChatControllerContext.Provider value={controller}>
{children}
</AIChatControllerContext.Provider>
);
}
+24 -595
View File
@@ -2,28 +2,10 @@
import * as zustand from 'zustand';
import { useCurrentContent } from '@/components/hooks';
import { useLanguage } from '@/intl/client';
import { tString } from '@/intl/translate';
import {
AIMessageRole,
AIMessageStepPhase,
type AIStreamResponse,
type AIStreamResponseToolCallPending,
type AIToolCallResult,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { AIMessageRole, AIMessageStepPhase, type AIStreamResponse } from '@gitbook/api';
import * as React from 'react';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
import { type AIChatReference, serializeReferences } from './references';
import { type RenderAIMessageOptions, streamAIChatResponse } from './server-actions';
import { getTools } from './tools';
import { useAIMessageContextRef } from './useAIMessageContext';
import { useNavigateToPageTool } from './useNavigateToPageTool';
import type { AIChatReference } from './references';
export type AIChatMessage = {
role: AIMessageRole;
@@ -145,8 +127,6 @@ type AIChatEventData<T extends AIChatEvent['type']> = Omit<
'type'
>;
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
@@ -175,10 +155,10 @@ export type AIChatController = {
) => () => void;
};
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
export const AIChatControllerContext = React.createContext<AIChatController | null>(null);
// Global state store for AI chat
const globalState = zustand.create<AIChatState>(() => {
export const globalAIChatState = zustand.create<AIChatState>(() => {
return {
opened: false,
responseId: null,
@@ -200,576 +180,28 @@ const globalState = zustand.create<AIChatState>(() => {
* Get the current state of the AI chat.
*/
export function useAIChatState(): AIChatState {
const state = zustand.useStore(globalState);
const state = zustand.useStore(globalAIChatState);
return state;
}
function notify(
listeners: AIChatEventListener[] | undefined,
input: Omit<AIChatEvent, 'type'>
): void {
if (!listeners) return;
// Defer event listeners to next tick so React can process state updates first
setTimeout(() => {
listeners.forEach((listener) => listener(input));
}, 0);
}
/**
* Provide the controller to interact with the AI chat.
* Inert controller returned when no AIChatProvider is mounted (AI chat disabled for the site).
* Lets always-mounted consumers (search, page actions, …) call the hook unconditionally without
* pulling the chat runtime into their bundle or throwing at render time.
*/
export function AIChatProvider(props: {
renderMessageOptions?: RenderAIMessageOptions;
children: React.ReactNode;
}) {
const { renderMessageOptions, children } = props;
const messageContextRef = useAIMessageContextRef();
const trackEvent = useTrackEvent();
const setSearchState = useSetSearchState();
const { siteSpaceId } = useCurrentContent();
const language = useLanguage();
// Built-in tools exposed to the assistant (e.g. navigating to a page). The tool has a stable
// identity, so it can be referenced directly from the streaming callback.
const navigateToPageTool = useNavigateToPageTool();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
globalState.setState((state) => ({ ...state, opened: false }));
// Clear ask parameter but keep other search state
setSearchState((prev) => ({
ask: null,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]);
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
// Stream a message with the AI backend
const streamResponse = React.useCallback(
async (input: {
/** Text message to send to the AI backend */
message?: string;
/** User-typed prompt; compared against state.query to abort stale streams */
userQuery?: string;
/** Tool call to send to the AI backend */
toolCall?: AIToolCallResult;
}) => {
globalState.setState((state) => {
return {
...state,
followUpSuggestions: [],
control: null,
responding: true,
loading: true,
error: false,
messages: [
...state.messages,
{
role: AIMessageRole.Assistant,
content: null, // Placeholder for streaming response
activity: getDefaultAIChatMessageActivity(),
},
],
};
});
// A stream becomes stale once a newer turn (or a clear) has replaced its
// query. Because `responding` clears on `response_finish` — before follow-up
// suggestions finish streaming — the user can start a new turn while this one
// is still wrapping up. A stale stream must not mutate the shared
// loading/responding state, which now belongs to the active turn; otherwise
// it would make the UI look idle mid-response. (`userQuery` is only set for
// user-initiated turns, not tool-call continuations.)
const isSuperseded = () =>
!!input.userQuery && globalState.getState().query !== input.userQuery;
// Execute a tool call
const executeToolCall = async (event: AIStreamResponseToolCallPending) => {
const tools = getTools([navigateToPageTool]);
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef || !('execute' in toolDef)) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
try {
const result = await toolDef.execute(event.toolCall.input);
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
} catch (error) {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: {
error: error instanceof Error ? error.message : 'Unknown error',
},
summary: {
icon: 'bomb',
text: 'An error occurred while executing the tool',
},
},
});
}
};
let toolToExecute: AIStreamResponseToolCallPending | null = null;
try {
const tools = getTools([navigateToPageTool]);
const stream = await streamAIChatResponse({
message: input.message,
toolCall: input.toolCall,
messageContext: messageContextRef.current,
previousResponseId: globalState.getState().responseId ?? undefined,
session: await getInsightsSession(),
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description,
// Issue with the schema generated by Zod and Next.js serialization.
inputSchema: tool.inputSchema,
})),
options: {
withLinkPreviews: renderMessageOptions?.withLinkPreviews ?? true,
withToolCalls: renderMessageOptions?.withToolCalls ?? true,
asEmbeddable: renderMessageOptions?.asEmbeddable ?? false,
},
});
// Process streaming response
for await (const data of stream) {
if (!data) continue;
if (isSuperseded()) {
// Chat was cleared or a newer turn started; stop processing.
break;
}
const event = data.event;
switch (event.type) {
case 'response_finish': {
globalState.setState((state) => ({
...state,
responseId: event.response.id ?? null,
// Mark as not responding when the response is finished
// Even if the stream might continue as we receive 'response_followup_suggestion'
responding: false,
error: false,
}));
break;
}
case 'response_followup_suggestion': {
globalState.setState((state) => ({
...state,
followUpSuggestions: [
...state.followUpSuggestions,
...event.suggestions,
],
}));
break;
}
case 'response_tool_call_pending': {
const toolDef = tools.find((tool) => tool.name === event.toolCall.tool);
if (!toolDef) {
throw new Error(`Tool ${event.toolCall.tool} not found`);
}
if ('createControl' in toolDef) {
globalState.setState((state) => ({
...state,
control: toolDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: event.toolCall.input as any,
language,
send: async (result) => {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: result.output,
summary: result.summary,
},
});
},
}),
}));
break;
}
const confirmation = 'confirmation' in toolDef && toolDef.confirmation;
if (confirmation) {
globalState.setState((state) => ({
...state,
control: ConfirmControlDef.createControl({
context: {
toolCall: event.toolCall,
toolCallId: event.toolCallId,
},
input: {
label: confirmation.label,
icon: confirmation.icon,
},
language,
send: async (result) => {
const output = ConfirmControlOutputSchema.parse(
result.output
);
switch (output.result) {
case 'cancelled': {
await streamResponse({
toolCall: {
tool: event.toolCall.tool,
toolCallId: event.toolCallId,
output: { cancelled: true },
summary: {
icon: 'forward',
text: tString(
language,
'tool_call_skipped',
confirmation.label
),
},
},
});
break;
}
case 'confirmed':
await executeToolCall(event);
break;
default:
assertNever(output.result);
}
},
}),
}));
break;
}
toolToExecute = event;
break;
}
}
// Update the assistant message with streamed content
globalState.setState((state) => ({
...state,
messages: [
...state.messages.slice(0, -1),
{
role: AIMessageRole.Assistant,
content: data.content,
activity: updateAIChatMessageActivity(
state.messages[state.messages.length - 1]?.activity ??
getDefaultAIChatMessageActivity(),
event
),
},
],
}));
}
// If a newer turn replaced this one while we were finishing (e.g.
// streaming follow-up suggestions after `response_finish`), abandon this
// stale stream without executing leftover tools or clearing the shared
// loading/responding state, which now belongs to the active turn.
if (isSuperseded()) {
return;
}
// Execute the tool call if it doesn't require confirmation.
// When a tool call (or control) keeps the turn going, `loading`
// stays true: either the recursive `streamResponse` will clear it
// when its stream settles, or it is cleared below once the loop ends
// (e.g. while waiting on a user confirmation control).
if (toolToExecute) {
await executeToolCall(toolToExecute);
} else {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: false,
}));
// Turn settled: send the next queued follow-up (oldest first). Held back while a
// control is pending, since posting would throw; it flushes after that resolves.
const { queuedMessages, control: activeControl } = globalState.getState();
const [next, ...rest] = queuedMessages;
if (next !== undefined && !activeControl) {
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
postMessageRef.current?.({ message: next });
}
}
} catch (error) {
console.error('Error streaming AI response', error);
// Don't surface a stale stream's error onto the active turn.
if (!isSuperseded()) {
globalState.setState((state) => ({
...state,
responding: false,
loading: false,
error: true,
}));
}
}
},
[
messageContextRef.current,
renderMessageOptions?.withLinkPreviews,
renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable,
language,
navigateToPageTool,
]
);
// Post a message to the AI chat
const onPostMessage = React.useCallback(
async (input: { message: string }) => {
const { query, messages, control, references, responding } = globalState.getState();
if (control) {
throw new Error("We can't post a message when a control is active");
}
// Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
if (responding) {
globalState.setState((state) => ({
...state,
queuedMessages: [...state.queuedMessages, input.message],
}));
return;
}
const wireMessage = `${serializeReferences(references)}${input.message}`;
// For first message, update the ask parameter in URL
if (messages.length === 0) {
if (siteSpaceId) {
addRecentSearchQuery(siteSpaceId, input.message, 'ask');
}
setSearchState((prev) => ({
ask: input.message,
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message && references.length === 0) {
// Return early if the message is the same as the previous message
// (unless new references are staged, which change the payload)
globalState.setState((state) => ({
...state,
opened: true,
}));
return;
}
trackEvent({ type: 'ask_question', query: input.message });
// Add user message and placeholder for AI response
globalState.setState((state) => {
return {
...state,
messages: [
...state.messages,
{
role: AIMessageRole.User,
content: input.message,
query: input.message,
references,
},
],
query: input.message,
followUpSuggestions: [],
responding: true,
error: false,
initialQuery: state.initialQuery ?? input.message,
references: [],
};
});
streamResponse({ message: wireMessage, userQuery: input.message });
},
[setSearchState, siteSpaceId, trackEvent, streamResponse]
);
// Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
postMessageRef.current = onPostMessage;
// Remove a follow-up queued while the assistant is still answering (the × on the affordance).
const onCancelQueuedMessage = React.useCallback((index: number) => {
globalState.setState((state) =>
index < 0 || index >= state.queuedMessages.length
? state
: {
...state,
queuedMessages: state.queuedMessages.filter((_, i) => i !== index),
}
);
}, []);
// Clear the conversation and reset ask parameter
const onClear = React.useCallback(() => {
globalState.setState((state) => ({
opened: state.opened,
responding: false,
loading: false,
messages: [],
query: null,
followUpSuggestions: [],
control: null,
responseId: null,
error: false,
initialQuery: null,
references: [],
queuedMessages: [],
}));
// Reset ask parameter to empty string (keeps chat open but clears content)
setSearchState((prev) => ({
ask: '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false,
}));
}, [setSearchState]);
const onAddReference = React.useCallback((ref: AIChatReference) => {
globalState.setState((state) => {
if (state.references.some((existingRef) => existingRef.id === ref.id)) {
return state;
}
return {
...state,
references: [...state.references, ref],
};
});
return ref.id;
}, []);
const onRemoveReference = React.useCallback((id: string) => {
globalState.setState((state) => {
if (!state.references.some((ref) => ref.id === id)) {
return state;
}
return {
...state,
references: state.references.filter((ref) => ref.id !== id),
};
});
}, []);
const onClearReferences = React.useCallback(() => {
globalState.setState((state) => {
if (state.references.length === 0) {
return state;
}
return { ...state, references: [] };
});
}, []);
const onFocus = React.useCallback(() => {
notify(eventsRef.current.get('focus'), {});
}, []);
const onSetDraft = React.useCallback((draft: string) => {
globalState.setState({ draft });
}, []);
const onEvent = React.useCallback(
<T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => {
const listeners = eventsRef.current.get(event) || [];
listeners.push(listener as AIChatEventListener);
eventsRef.current.set(event, listeners);
return () => {
const currentListeners = eventsRef.current.get(event) || [];
eventsRef.current.set(
event,
currentListeners.filter((l) => l !== listener)
);
};
},
[]
);
const controller = React.useMemo(() => {
return {
open: onOpen,
close: onClose,
clear: onClear,
postMessage: onPostMessage,
addReference: onAddReference,
removeReference: onRemoveReference,
clearReferences: onClearReferences,
focus: onFocus,
setDraft: onSetDraft,
cancelQueuedMessage: onCancelQueuedMessage,
on: onEvent,
};
}, [
onOpen,
onClose,
onClear,
onPostMessage,
onAddReference,
onRemoveReference,
onClearReferences,
onFocus,
onSetDraft,
onCancelQueuedMessage,
onEvent,
]);
return (
<AIChatControllerContext.Provider value={controller}>
{children}
</AIChatControllerContext.Provider>
);
}
const NOOP_AI_CHAT_CONTROLLER: AIChatController = {
open: () => {},
close: () => {},
postMessage: () => {},
clear: () => {},
addReference: (ref) => ref.id,
removeReference: () => {},
clearReferences: () => {},
focus: () => {},
setDraft: () => {},
cancelQueuedMessage: () => {},
on: () => () => {},
};
/**
* Get the controller to interact with the AI chat.
@@ -777,10 +209,7 @@ export function AIChatProvider(props: {
*/
export function useAIChatController(): AIChatController {
const controller = React.useContext(AIChatControllerContext);
if (!controller) {
throw new Error('useAIChatController must be used within an AIChatProvider');
}
return controller;
return controller ?? NOOP_AI_CHAT_CONTROLLER;
}
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
@@ -823,7 +252,7 @@ function getLatestAssistantMessage(messages: AIChatMessage[]) {
return null;
}
function updateAIChatMessageActivity(
export function updateAIChatMessageActivity(
activity: AIChatMessageActivity,
event: AIStreamResponse
): AIChatMessageActivity {
@@ -849,7 +278,7 @@ function updateAIChatMessageActivity(
}
}
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
export function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
return {
currentPhase: undefined,
toolCount: 0,
@@ -6,19 +6,32 @@ import type { AIToolDefinition } from '@gitbook/api';
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { NavigationStatusContext } from '../hooks';
import { normalizePathname, resolveNavigationTarget } from './navigation';
import { resolveAINavigationLink } from './server-actions';
const NavigateToPageInputSchema = z.object({
url: z
.string()
.describe(
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).'
),
});
// Hand-written JSON Schema: this hook runs in the always-mounted provider, so pulling zod +
// zod-to-json-schema here would keep the entire zod chunk eager for every visitor.
const NAVIGATE_TO_PAGE_INPUT_SCHEMA = {
type: 'object',
properties: {
url: {
type: 'string',
description:
'The URL of the documentation page to open. Must be a page within this documentation site (the same URL you would use to link to the page). Can include a section anchor (e.g. #section).',
},
},
required: ['url'],
additionalProperties: false,
} as AIToolDefinition['inputSchema'];
function parseNavigateToPageInput(input: unknown): { url: string } {
const url = (input as { url?: unknown } | null | undefined)?.url;
if (typeof url !== 'string') {
throw new Error('Invalid input for navigateToPage: expected { url: string }');
}
return { url };
}
/**
* Resolve once the SPA navigation to `pathname` has committed (the browser URL reflects it), or
@@ -70,12 +83,10 @@ export function useNavigateToPageTool(): GitBookIntegrationTool {
name: 'navigateToPage',
description:
'Navigate the user to a page in the documentation. The page opens instantly without asking for confirmation, so only use it when the user clearly wants to be taken to a specific page. Provide the URL of the page within this documentation site.',
inputSchema: zodToJsonSchema(
NavigateToPageInputSchema as any
) as AIToolDefinition['inputSchema'],
inputSchema: NAVIGATE_TO_PAGE_INPUT_SCHEMA,
execute: async (input) => {
const { router, language, onNavigationClick } = ref.current;
const { url } = NavigateToPageInputSchema.parse(input);
const { url } = parseNavigateToPageInput(input);
// The assistant references pages using the stable content-ref scheme
// (e.g. `/spaces/<id>/pages/<id>`). Resolve it server-side to the real site link.
@@ -4,6 +4,7 @@ import fnv1a from '@sindresorhus/fnv1a';
import { useAIChatController, useAIConfig } from '@/components/AI';
import { Button } from '@/components/primitives';
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import { t, tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -23,6 +24,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
const language = useLanguage();
const chatController = useAIChatController();
if (!isAIChatEnabled(config.aiMode)) {
return null;
}
const onClick = () => {
const text = content.trim();
if (!text) {
@@ -1,377 +1,32 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import dynamic from 'next/dynamic';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { ChangedPagesButton } from './ChangedPagesButton';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
Toolbar,
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
import type { AdminToolbarClientProps } from './types';
// Loaded on demand so its Framer Motion + toolbar UI never ship in the main client chunk.
// Anonymous public visitors — who can never see the toolbar — pay nothing.
const AdminToolbarFull = dynamic(
() => import('./AdminToolbarFull').then((mod) => mod.AdminToolbarFull),
{ ssr: false }
);
/**
* Lightweight gate deciding whether the admin toolbar can appear for this viewer, before
* loading any of its heavy UI. It renders for editor contexts (change request / prior revision)
* and for authenticated members of the organization owning the site; for everyone else it renders
* nothing and the full toolbar bundle is never requested.
*/
export function AdminToolbarClient(props: AdminToolbarClientProps) {
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
const {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
} = useToolbarVisibility({
onPersistentClose,
onSessionClose,
onToggleMinify,
});
const { context } = props;
const visitor = useVisitor();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
closeSession,
closePersistent,
shouldAutoExpand,
};
const isEditorContext =
Boolean(context.changeRequest) || context.revisionId !== context.space.revision;
const isOrgMember = visitor?.organizationId === context.organizationId;
if (hidden) {
if (!isEditorContext && !isOrgMember) {
return null;
}
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<ChangeRequestToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<RevisionToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitor?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
return null;
}
/**
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
*/
export function ToolbarControlsWrapper(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return (
<ToolbarControlsProvider value={value}>
<IframeWrapper>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
interface ToolbarViewProps {
context: AdminToolbarContext;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
function ChangeRequestToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
}
const author = changeRequest.createdBy.displayName;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: changeRequest.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
href={getToolbarHref({
href: `${changeRequest.urls.app}~/comments`,
siteId: site.id,
buttonId: 'comment',
})}
icon="comment"
/>
{/* Open published/live site */}
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
{/* Open CR in GitBook */}
<ToolbarButton
title="View change request in GitBook"
href={getToolbarHref({
href: changeRequest.urls.app,
siteId: site.id,
buttonId: 'change-request',
})}
icon="code-pull-request"
/>
</ToolbarActions>
</Toolbar>
);
}
function RevisionToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
}
const gitURL = revision.git?.url;
const isGitHub = gitURL?.includes('github.com');
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Open commit in Git client */}
<ToolbarButton
title={
gitURL ? (
`Open commit in ${gitProvider}`
) : (
<div className="flex items-center gap-2">
Setup GitSync to edit using Git{' '}
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
<a
href="https://gitbook.com/docs/getting-started/git-sync"
target="_blank"
rel="noopener noreferrer"
className=""
onClick={(e) => e.stopPropagation()}
>
Learn more
</a>
<Icon icon="arrow-up-right" className="size-3" />
</div>
</div>
)
}
href={gitURL}
disabled={!gitURL}
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
/>
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
<ToolbarButton
title="View this revision in GitBook"
href={getToolbarHref({
href: revision.urls.app,
siteId: site.id,
buttonId: 'revision',
})}
icon="code-commit"
/>
</ToolbarActions>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="folder-gear"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize site"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
buttonId: 'customize',
})}
icon="palette"
/>
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
icon="chart-simple"
/>
</ToolbarActions>
</Toolbar>
);
}
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
<ToolbarButton
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
/**
* Append utm parameters to a URL to track usage of the toolbar.
*/
function getToolbarHref({
href,
siteId,
buttonId,
}: { href: string; siteId: string; buttonId: string }) {
const url = new URL(href);
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'toolbar');
url.searchParams.set('utm_campaign', siteId);
url.searchParams.set('utm_content', buttonId);
return url.toString();
return <AdminToolbarFull {...props} />;
}
@@ -0,0 +1,382 @@
'use client';
import { Icon } from '@gitbook/icons';
import { MotionConfig, motion } from 'motion/react';
import { useCheckForContentUpdate } from '../AutoRefreshContent';
import { useVisitor } from '../Insights';
import { useCurrentPagePath } from '../hooks';
import { ChangedPagesButton } from './ChangedPagesButton';
import { HideToolbarButton } from './HideToolbarButton';
import { IframeWrapper } from './IframeWrapper';
import { RefreshContentButton } from './RefreshContentButton';
import {
Toolbar,
ToolbarBody,
ToolbarButton,
ToolbarButtonGroup,
type ToolbarButtonProps,
ToolbarSubtitle,
ToolbarTitle,
} from './Toolbar';
import {
type ToolbarControlsContextValue,
ToolbarControlsProvider,
} from './ToolbarControlsContext';
import { ToolbarDate } from './ToolbarDate';
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
import { useToolbarVisibility } from './utils';
/**
* The full toolbar UI. Pulls in Framer Motion and every toolbar variant, so it is only
* loaded (via a dynamic import in AdminToolbarClient) for viewers who can actually see it —
* never for anonymous public visitors.
*/
export function AdminToolbarFull(props: AdminToolbarClientProps) {
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
const {
minified,
setMinified,
shouldAutoExpand,
hidden,
minimize,
closeSession,
closePersistent,
} = useToolbarVisibility({
onPersistentClose,
onSessionClose,
onToggleMinify,
});
const visitor = useVisitor();
const toolbarControls: ToolbarControlsContextValue = {
minimize,
closeSession,
closePersistent,
shouldAutoExpand,
};
if (hidden) {
return null;
}
// If there is a change request, show the change request toolbar
if (context.changeRequest) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<ChangeRequestToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
if (context.revisionId !== context.space.revision) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<RevisionToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
if (visitor?.organizationId === context.organizationId) {
return (
<ToolbarControlsWrapper value={toolbarControls}>
<AuthenticatedUserToolbar
context={context}
minified={minified}
onMinifiedChange={setMinified}
/>
</ToolbarControlsWrapper>
);
}
return null;
}
/**
* Reusable wrapper that provides tooling and containers that are used by all types of toolbar views.
*/
export function ToolbarControlsWrapper(
props: React.PropsWithChildren<{ value: ToolbarControlsContextValue | null }>
) {
const { children, value } = props;
return (
<ToolbarControlsProvider value={value}>
<IframeWrapper>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</IframeWrapper>
</ToolbarControlsProvider>
);
}
interface ToolbarViewProps {
context: AdminToolbarContext;
minified: boolean;
onMinifiedChange: (value: boolean) => void;
}
function ChangeRequestToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { changeRequest, site } = context;
if (!changeRequest) {
throw new Error('Change request is not set');
}
const author = changeRequest.createdBy.displayName;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: changeRequest.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle
prefix={`Change #${changeRequest.number}:`}
suffix={`${changeRequest.subject || 'Untitled'}`}
/>
<ToolbarSubtitle
subtitle={
<>
<ToolbarDate value={changeRequest.updatedAt} />{' '}
<motion.span layout="position">by {author}</motion.span>
</>
}
/>
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Edit in GitBook */}
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
{/* Comment in app */}
<ToolbarButton
title="Comment in a GitBook"
href={getToolbarHref({
href: `${changeRequest.urls.app}~/comments`,
siteId: site.id,
buttonId: 'comment',
})}
icon="comment"
/>
{/* Open published/live site */}
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
{/* Open CR in GitBook */}
<ToolbarButton
title="View change request in GitBook"
href={getToolbarHref({
href: changeRequest.urls.app,
siteId: site.id,
buttonId: 'change-request',
})}
icon="code-pull-request"
/>
</ToolbarActions>
</Toolbar>
);
}
function RevisionToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, site } = context;
if (!revision) {
throw new Error('Revision is not set');
}
const gitURL = revision.git?.url;
const isGitHub = gitURL?.includes('github.com');
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle prefix="Prior version of " suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* View a popover with quick links to the changed pages */}
<ChangedPagesButton changedPages={context.changedPages} />
{/* Open commit in Git client */}
<ToolbarButton
title={
gitURL ? (
`Open commit in ${gitProvider}`
) : (
<div className="flex items-center gap-2">
Setup GitSync to edit using Git{' '}
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
<a
href="https://gitbook.com/docs/getting-started/git-sync"
target="_blank"
rel="noopener noreferrer"
className=""
onClick={(e) => e.stopPropagation()}
>
Learn more
</a>
<Icon icon="arrow-up-right" className="size-3" />
</div>
</div>
)
}
href={gitURL}
disabled={!gitURL}
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
/>
{site.urls.published ? (
<ToolbarButton
title="Open live site"
href={getToolbarHref({
href: site.urls.published,
siteId: site.id,
buttonId: 'production-site',
})}
icon="globe"
/>
) : null}
<ToolbarButton
title="View this revision in GitBook"
href={getToolbarHref({
href: revision.urls.app,
siteId: site.id,
buttonId: 'revision',
})}
icon="code-commit"
/>
</ToolbarActions>
</Toolbar>
);
}
function AuthenticatedUserToolbar(props: ToolbarViewProps) {
const { context, minified, onMinifiedChange } = props;
const { revision, space, site } = context;
const { refreshForUpdates, updated } = useCheckForContentUpdate({
revisionId: space.revision,
});
return (
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
<ToolbarBody>
<ToolbarTitle suffix={context.site.title} />
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
</ToolbarBody>
<ToolbarActions>
{/* Refresh to retrieve latest changes */}
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
{/* Edit in GitBook */}
<EditPageButton href={space.urls.app} siteId={site.id} />
{/* Open site in GitBook */}
<ToolbarButton
title="View site configuration"
href={getToolbarHref({
href: site.urls.app,
siteId: site.id,
buttonId: 'site',
})}
icon="folder-gear"
/>
{/* Customize in GitBook */}
<ToolbarButton
title="Customize site"
href={getToolbarHref({
href: `${site.urls.app}/customization/general`,
siteId: site.id,
buttonId: 'customize',
})}
icon="palette"
/>
{/* Open insights in GitBook */}
<ToolbarButton
title="Open insights"
href={getToolbarHref({
href: `${site.urls.app}/insights`,
siteId: site.id,
buttonId: 'insights',
})}
icon="chart-simple"
/>
</ToolbarActions>
</Toolbar>
);
}
function ToolbarActions(props: { children: React.ReactNode }) {
const { children } = props;
return (
<ToolbarButtonGroup>
{children}
<HideToolbarButton />
</ToolbarButtonGroup>
);
}
function EditPageButton(props: {
href: string;
siteId: string;
motionValues?: ToolbarButtonProps['motionValues'];
}) {
const { href, motionValues, siteId } = props;
const pagePath = useCurrentPagePath();
return (
<ToolbarButton
title="Edit this page"
href={getToolbarHref({
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
siteId,
buttonId: 'edit',
})}
icon="pen-to-square"
motionValues={motionValues}
/>
);
}
/**
* Append utm parameters to a URL to track usage of the toolbar.
*/
function getToolbarHref({
href,
siteId,
buttonId,
}: { href: string; siteId: string; buttonId: string }) {
const url = new URL(href);
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'toolbar');
url.searchParams.set('utm_campaign', siteId);
url.searchParams.set('utm_content', buttonId);
return url.toString();
}
@@ -0,0 +1,9 @@
'use client';
import { createLazyStylesheet } from '../createLazyStylesheet';
/**
* Lazy-loads the ContentKit stylesheet so it only downloads on pages that actually render
* an integration block, instead of shipping in every page's CSS chunk.
*/
export default createLazyStylesheet(() => import('./contentkit.css'));
@@ -2,10 +2,10 @@ import { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_INTEGRATIONS_HOST } from '@/
import { tcls } from '@/lib/tailwind';
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
import React from 'react';
import type { BlockProps } from '../Block';
import './contentkit.css';
import type { GitBookLinker } from '@/lib/links';
import type { BlockProps } from '../Block';
import {
ContentKitWithClientContext,
type WebframeLinkerData,
@@ -15,6 +15,9 @@ import { contentKitServerContext } from './contentkit';
import { fetchSafeIntegrationUI } from './render';
import { renderIntegrationUi } from './server-actions';
// Lazy so the ContentKit CSS is only fetched on pages that render an integration block.
const ContentKitStyles = React.lazy(() => import('./ContentKitStyles'));
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
const { block, context, style } = props;
@@ -102,6 +105,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
return (
<div className={tcls(style)}>
<ContentKitStyles />
{useClientContext ? (
<ContentKitWithClientContext
{...contentKitProps}
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIStyles, getOpenAPIContext } from './context';
/**
* Render an openapi block or an openapi-operation block.
@@ -14,6 +14,7 @@ export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlo
const { style } = props;
return (
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<OpenAPIStyles />
<OpenAPIOperationBody {...props} />
</div>
);
@@ -4,7 +4,7 @@ import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIStyles, getOpenAPIContext } from './context';
/**
* Render an openapi-schemas block.
@@ -13,6 +13,7 @@ export async function OpenAPISchemas(props: BlockProps<OpenAPISchemasBlock>) {
const { style } = props;
return (
<div className={tcls('flex w-full', style, 'max-w-full')}>
<OpenAPIStyles />
<OpenAPISchemasBody {...props} />
</div>
);
@@ -0,0 +1,9 @@
'use client';
import { createLazyStylesheet } from '../createLazyStylesheet';
/**
* Lazy-loads the OpenAPI/Scalar stylesheet. Kept out of the static import graph so the
* ~148KB Scalar CSS only downloads on pages that actually render an OpenAPI block.
*/
export default createLazyStylesheet(() => import('./style.css'));
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
import type { OpenAPIWebhookBlock } from '@/lib/openapi/types';
import type { BlockProps } from '../Block';
import { getOpenAPIContext } from './context';
import { OpenAPIStyles, getOpenAPIContext } from './context';
/**
* Render an openapi block or an openapi-webhook block.
@@ -14,6 +14,7 @@ export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
const { style } = props;
return (
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
<OpenAPIStyles />
<OpenAPIWebhookBody {...props} />
</div>
);
@@ -1,13 +1,13 @@
import type { JSONDocument } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
import React from 'react';
import type { BlockProps } from '../Block';
import { PlainCodeBlock } from '../CodeBlock';
import { DocumentView } from '../DocumentView';
import { Heading } from '../Heading';
import './style.css';
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
import type { GitBookAnyContext } from '@/lib/context';
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
@@ -17,6 +17,12 @@ import type {
OpenAPIWebhookBlock,
} from '@/lib/openapi/types';
/**
* Lazy loader for the OpenAPI/Scalar stylesheet, rendered by each OpenAPI block so the CSS
* is only fetched on pages that use one.
*/
export const OpenAPIStyles = React.lazy(() => import('./OpenAPIStyles'));
/**
* Get the OpenAPI context to render a block.
*/
@@ -116,52 +116,8 @@ button.openapi-mcp {
@apply !mb-0;
}
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
/* Method / status-code tag styles moved to `./tags.css`, loaded globally so the sidebar method
* badges are styled even before an OpenAPI block mounts this deferred stylesheet. */
/* URL */
.openapi-url {
@@ -0,0 +1,56 @@
/*
* OpenAPI method / status-code tag styles.
*
* These live outside the deferred `style.css` and are loaded from the global stylesheet because
* the HTTP method badge (`OpenAPIMethodBadge`) renders in the always-present sidebar (table of
* contents) on every page — not only on pages that mount an OpenAPI block. If they stayed in the
* lazily-loaded stylesheet, the sidebar badges would be unstyled until an OpenAPI page pulled in
* the heavy Scalar CSS.
*/
/* Method Tags */
.openapi-method,
.openapi-statuscode {
@apply m-0 h-5 min-w-9 justify-center rounded-md text-xs straight-corners:rounded-none circular-corners:rounded-lg uppercase font-mono items-center shrink-0 font-semibold px-1.5 py-0.5 text-tint-12/8 leading-tight align-middle inline-flex whitespace-nowrap;
}
.openapi-method-small {}
.openapi-method-medium {
@apply m-0 px-2.5 py-1 h-6 text-[0.813rem];
}
.toclink .openapi-method {
@apply text-[0.625rem] flex items-center justify-center;
}
.openapi-method-get,
.openapi-statuscode-success {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
}
.openapi-method-post,
.openapi-statuscode-redirection {
@apply bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100;
}
.openapi-method-put,
.openapi-statuscode-informational {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
}
.openapi-method-patch {
@apply bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100;
}
.openapi-method-delete,
.openapi-statuscode-error {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
}
.openapi-method-head,
.openapi-method-options,
.openapi-method-trace,
.openapi-method-hook {
@apply bg-tint;
}
@@ -0,0 +1,18 @@
'use client';
/**
* Factory for a fire-and-forget loader of a code-split stylesheet: the CSS only downloads on
* pages that render the associated block, instead of shipping in every page's CSS chunk. The
* returned component renders nothing.
*/
export function createLazyStylesheet(load: () => Promise<unknown>) {
let loaded = false;
return function LazyStylesheet() {
// Load during render (not in an effect) so the request starts as early as possible.
if (!loaded && typeof window !== 'undefined') {
loaded = true;
load();
}
return null;
};
}
@@ -4,6 +4,10 @@
@import "./prose.css";
/* OpenAPI method/status-code tags render in the always-present sidebar, so their styles must
ship globally instead of in the deferred OpenAPI stylesheet. */
@import "../DocumentView/OpenAPI/tags.css";
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -3,6 +3,7 @@
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { CustomizationSearchStyle } from '@gitbook/api';
import dynamic from 'next/dynamic';
import React, { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { AIChatButton } from '../AIChat';
@@ -10,13 +11,18 @@ import { useIsMobile } from '../hooks/useIsMobile';
import { Button, Popover } from '../primitives';
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
import { SideSheet } from '../primitives/SideSheet';
import { SearchFrame } from './SearchFrame';
import { SearchInput } from './SearchInput';
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
import { SearchScopeControl } from './SearchScopeControl';
import type { SearchBaseProps } from './search-props';
import { useSearchController } from './useSearchController';
// The results panel (and its ranking/AI code) only appears once search is used, so load it on
// demand instead of shipping it in every page's client bundle.
const SearchFrame = dynamic(() => import('./SearchFrame').then((mod) => mod.SearchFrame), {
ssr: false,
});
interface SearchContainerProps extends SearchBaseProps {
style: CustomizationSearchStyle;
className?: string;
@@ -1,6 +1,6 @@
'use client';
import { Document, type DocumentValue } from 'flexsearch';
import type { Document, DocumentValue } from 'flexsearch';
import React from 'react';
interface Breadcrumb {
@@ -63,8 +63,11 @@ const cachedPageData = new Map<
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
const index = new Document<IndexPage>({
function buildLangIndex(
DocumentCtor: typeof import('flexsearch').Document,
pages: RawIndexPage[]
): Document<IndexPage> {
const index = new DocumentCtor<IndexPage>({
document: {
id: 'id',
index: ['title', 'description'],
@@ -110,7 +113,9 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
}
pendingFetch = (async () => {
const response = await fetch(indexURL);
// Load FlexSearch lazily so its code lands in an on-demand chunk instead of the
// main client bundle — it's only needed once the user actually searches.
const [{ Document }, response] = await Promise.all([import('flexsearch'), fetch(indexURL)]);
if (!response.ok) {
throw new Error(`Failed to fetch search index: ${response.status}`);
}
@@ -131,7 +136,7 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
// Build one FlexSearch Document per language group
for (const [lang, pages] of pagesByLang) {
cachedIndexes.set(lang, buildLangIndex(pages));
cachedIndexes.set(lang, buildLangIndex(Document, pages));
}
return cachedIndexes;
@@ -156,8 +161,11 @@ export function useLocalSearchResults(props: {
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
filterSiteSpaceIds?: string[];
disabled?: boolean;
/** Whether search is active (opened or has a query). The whole-site index is only
* fetched/built once this is true, so an idle page never downloads it. */
active?: boolean;
}): LocalSearchState {
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false } = props;
const { query, indexURL, lang, filterSiteSpaceIds, disabled = false, active = true } = props;
const [state, setState] = React.useState<LocalSearchState>({
results: [],
@@ -168,13 +176,17 @@ export function useLocalSearchResults(props: {
// Track whether the indexes are loaded so the search effect re-runs after load
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
// Load the indexes once
// Load the indexes once search becomes active (opened or queried).
React.useEffect(() => {
if (cachedIndexes.size > 0) {
setIndexReady(true);
return;
}
if (!active) {
return;
}
let cancelled = false;
setState((prev) => ({ ...prev, fetching: true, error: false }));
@@ -194,7 +206,7 @@ export function useLocalSearchResults(props: {
return () => {
cancelled = true;
};
}, [indexURL]);
}, [indexURL, active]);
// Perform instant local search whenever query, lang, or index readiness changes
React.useEffect(() => {
@@ -200,6 +200,9 @@ export function useSearchController(props: SearchBaseProps) {
const { results, fetching, error, abort } = useSearchResults({
asEmbeddable,
disabled: !(state?.query || withAI),
// Only load the local search index once the user shows intent (opens search
// or has a query). Avoids fetching the whole-site index on every page view.
active: Boolean(state?.open || state?.query),
query: normalizedQuery,
siteSpaceId: siteSpace.id,
siteSpaceIds,
@@ -42,6 +42,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
export function useSearchResults(props: {
asEmbeddable?: boolean;
disabled: boolean;
/** Whether the search surface is active (opened or has a query). Gates loading of the local index. */
active: boolean;
query: string;
siteSpaceId: string;
siteSpaceIds: string[];
@@ -59,6 +61,7 @@ export function useSearchResults(props: {
const {
asEmbeddable,
disabled,
active,
query,
siteSpaceId,
siteSpaceIds,
@@ -82,6 +85,7 @@ export function useSearchResults(props: {
indexURL,
lang,
disabled,
active,
filterSiteSpaceIds,
});
@@ -39,12 +39,6 @@ export async function SiteLayout(props: {
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
}
// We also preload the site index
ReactDOM.preload(`${context.linker.siteBasePath}~gitbook/site-index`, {
as: 'fetch',
type: 'application/json',
});
scripts.forEach(({ script }) => {
ReactDOM.preload(script, {
as: 'script',
@@ -9,8 +9,10 @@ import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_APP_URL } from '@/lib/env';
import { tcls } from '@/lib/tailwind';
import { AIChatProvider } from '../AI';
import type { RenderAIMessageOptions } from '../AI';
// Import directly (not via the AI barrel) so the chat runtime stays out of the graph of every
// consumer of '../AI'; the provider itself is only mounted when AI chat is enabled.
import { AIChatProvider } from '../AI/AIChatProvider';
import { AIChat, AskAITextSelection } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement';
@@ -88,9 +90,13 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
>
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
{isAIChatEnabled(customization.ai?.mode) ? (
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
{children}
</AIChatProvider>
) : (
children
)}
</InsightsProvider>
</VisitorProvider>
</CurrentContentProvider>