mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-08 22:31:01 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d25cc924e4 | |||
| 68e50d68e5 | |||
| 46b43c4372 | |||
| f8fd5e2bb4 | |||
| 9ab6cfb878 | |||
| c9a637e0d9 | |||
| c16ab0a2ce | |||
| fe20392d21 | |||
| 8a81d77151 | |||
| 55ac341cc9 | |||
| 2f5b0a7f8a | |||
| 99f751c060 |
@@ -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.
|
||||||
@@ -37,6 +37,16 @@ const nextConfig = {
|
|||||||
optimisticClientCache: false,
|
optimisticClientCache: false,
|
||||||
// Disable splitting the RSC in like 5 chunks
|
// Disable splitting the RSC in like 5 chunks
|
||||||
prefetchInlining: true,
|
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: {
|
env: {
|
||||||
|
|||||||
@@ -138,9 +138,7 @@
|
|||||||
"e2e-browserless": "bun test ./tests/",
|
"e2e-browserless": "bun test ./tests/",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": ["chrome >= 93, edge >= 93, firefox >= 92, safari >= 15.4, not dead"],
|
||||||
">0.3%, chrome >= 64, edge >= 79, firefox >= 67, opera >= 51, safari >= 12 and not dead"
|
|
||||||
],
|
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,28 +2,10 @@
|
|||||||
|
|
||||||
import * as zustand from 'zustand';
|
import * as zustand from 'zustand';
|
||||||
|
|
||||||
import { useCurrentContent } from '@/components/hooks';
|
import { AIMessageRole, AIMessageStepPhase, type AIStreamResponse } from '@gitbook/api';
|
||||||
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 * as React from 'react';
|
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 type { AnyAIControl } from './controls';
|
||||||
import { ConfirmControlDef, ConfirmControlOutputSchema } from './controls/ConfirmControl';
|
import type { AIChatReference } from './references';
|
||||||
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';
|
|
||||||
|
|
||||||
export type AIChatMessage = {
|
export type AIChatMessage = {
|
||||||
role: AIMessageRole;
|
role: AIMessageRole;
|
||||||
@@ -145,8 +127,6 @@ type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
|||||||
'type'
|
'type'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
|
|
||||||
|
|
||||||
export type AIChatController = {
|
export type AIChatController = {
|
||||||
/** Open the dialog */
|
/** Open the dialog */
|
||||||
open: () => void;
|
open: () => void;
|
||||||
@@ -175,10 +155,10 @@ export type AIChatController = {
|
|||||||
) => () => void;
|
) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
export const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||||
|
|
||||||
// Global state store for AI chat
|
// Global state store for AI chat
|
||||||
const globalState = zustand.create<AIChatState>(() => {
|
export const globalAIChatState = zustand.create<AIChatState>(() => {
|
||||||
return {
|
return {
|
||||||
opened: false,
|
opened: false,
|
||||||
responseId: null,
|
responseId: null,
|
||||||
@@ -200,576 +180,28 @@ const globalState = zustand.create<AIChatState>(() => {
|
|||||||
* Get the current state of the AI chat.
|
* Get the current state of the AI chat.
|
||||||
*/
|
*/
|
||||||
export function useAIChatState(): AIChatState {
|
export function useAIChatState(): AIChatState {
|
||||||
const state = zustand.useStore(globalState);
|
const state = zustand.useStore(globalAIChatState);
|
||||||
return state;
|
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: {
|
const NOOP_AI_CHAT_CONTROLLER: AIChatController = {
|
||||||
renderMessageOptions?: RenderAIMessageOptions;
|
open: () => {},
|
||||||
children: React.ReactNode;
|
close: () => {},
|
||||||
}) {
|
postMessage: () => {},
|
||||||
const { renderMessageOptions, children } = props;
|
clear: () => {},
|
||||||
|
addReference: (ref) => ref.id,
|
||||||
const messageContextRef = useAIMessageContextRef();
|
removeReference: () => {},
|
||||||
const trackEvent = useTrackEvent();
|
clearReferences: () => {},
|
||||||
const setSearchState = useSetSearchState();
|
focus: () => {},
|
||||||
const { siteSpaceId } = useCurrentContent();
|
setDraft: () => {},
|
||||||
const language = useLanguage();
|
cancelQueuedMessage: () => {},
|
||||||
|
on: () => () => {},
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the controller to interact with the AI chat.
|
* Get the controller to interact with the AI chat.
|
||||||
@@ -777,10 +209,7 @@ export function AIChatProvider(props: {
|
|||||||
*/
|
*/
|
||||||
export function useAIChatController(): AIChatController {
|
export function useAIChatController(): AIChatController {
|
||||||
const controller = React.useContext(AIChatControllerContext);
|
const controller = React.useContext(AIChatControllerContext);
|
||||||
if (!controller) {
|
return controller ?? NOOP_AI_CHAT_CONTROLLER;
|
||||||
throw new Error('useAIChatController must be used within an AIChatProvider');
|
|
||||||
}
|
|
||||||
return controller;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
|
export function getAIChatStatus(chat: AIChatState): AIChatStatus {
|
||||||
@@ -823,7 +252,7 @@ function getLatestAssistantMessage(messages: AIChatMessage[]) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAIChatMessageActivity(
|
export function updateAIChatMessageActivity(
|
||||||
activity: AIChatMessageActivity,
|
activity: AIChatMessageActivity,
|
||||||
event: AIStreamResponse
|
event: AIStreamResponse
|
||||||
): AIChatMessageActivity {
|
): AIChatMessageActivity {
|
||||||
@@ -849,7 +278,7 @@ function updateAIChatMessageActivity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
|
export function getDefaultAIChatMessageActivity(): AIChatMessageActivity {
|
||||||
return {
|
return {
|
||||||
currentPhase: undefined,
|
currentPhase: undefined,
|
||||||
toolCount: 0,
|
toolCount: 0,
|
||||||
|
|||||||
@@ -6,19 +6,32 @@ import type { AIToolDefinition } from '@gitbook/api';
|
|||||||
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
import type { GitBookIntegrationTool } from '@gitbook/browser-types';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { z } from 'zod';
|
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
||||||
import { NavigationStatusContext } from '../hooks';
|
import { NavigationStatusContext } from '../hooks';
|
||||||
import { normalizePathname, resolveNavigationTarget } from './navigation';
|
import { normalizePathname, resolveNavigationTarget } from './navigation';
|
||||||
import { resolveAINavigationLink } from './server-actions';
|
import { resolveAINavigationLink } from './server-actions';
|
||||||
|
|
||||||
const NavigateToPageInputSchema = z.object({
|
// Hand-written JSON Schema: this hook runs in the always-mounted provider, so pulling zod +
|
||||||
url: z
|
// zod-to-json-schema here would keep the entire zod chunk eager for every visitor.
|
||||||
.string()
|
const NAVIGATE_TO_PAGE_INPUT_SCHEMA = {
|
||||||
.describe(
|
type: 'object',
|
||||||
'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).'
|
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
|
* 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',
|
name: 'navigateToPage',
|
||||||
description:
|
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.',
|
'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(
|
inputSchema: NAVIGATE_TO_PAGE_INPUT_SCHEMA,
|
||||||
NavigateToPageInputSchema as any
|
|
||||||
) as AIToolDefinition['inputSchema'],
|
|
||||||
execute: async (input) => {
|
execute: async (input) => {
|
||||||
const { router, language, onNavigationClick } = ref.current;
|
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
|
// 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.
|
// (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 { useAIChatController, useAIConfig } from '@/components/AI';
|
||||||
import { Button } from '@/components/primitives';
|
import { Button } from '@/components/primitives';
|
||||||
|
import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
|
|||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
|
|
||||||
|
if (!isAIChatEnabled(config.aiMode)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
const text = content.trim();
|
const text = content.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
@@ -1,377 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Icon } from '@gitbook/icons';
|
import dynamic from 'next/dynamic';
|
||||||
import { MotionConfig, motion } from 'motion/react';
|
|
||||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
|
||||||
import { useVisitor } from '../Insights';
|
import { useVisitor } from '../Insights';
|
||||||
import { useCurrentPagePath } from '../hooks';
|
import type { AdminToolbarClientProps } from './types';
|
||||||
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';
|
|
||||||
|
|
||||||
|
// 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) {
|
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||||
const { context, onPersistentClose, onSessionClose, onToggleMinify } = props;
|
const { context } = props;
|
||||||
const {
|
|
||||||
minified,
|
|
||||||
setMinified,
|
|
||||||
shouldAutoExpand,
|
|
||||||
hidden,
|
|
||||||
minimize,
|
|
||||||
closeSession,
|
|
||||||
closePersistent,
|
|
||||||
} = useToolbarVisibility({
|
|
||||||
onPersistentClose,
|
|
||||||
onSessionClose,
|
|
||||||
onToggleMinify,
|
|
||||||
});
|
|
||||||
|
|
||||||
const visitor = useVisitor();
|
const visitor = useVisitor();
|
||||||
|
|
||||||
const toolbarControls: ToolbarControlsContextValue = {
|
const isEditorContext =
|
||||||
minimize,
|
Boolean(context.changeRequest) || context.revisionId !== context.space.revision;
|
||||||
closeSession,
|
const isOrgMember = visitor?.organizationId === context.organizationId;
|
||||||
closePersistent,
|
|
||||||
shouldAutoExpand,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (hidden) {
|
if (!isEditorContext && !isOrgMember) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there is a change request, show the change request toolbar
|
return <AdminToolbarFull {...props} />;
|
||||||
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,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 { tcls } from '@/lib/tailwind';
|
||||||
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
import type { DocumentBlockIntegration, RenderIntegrationUI } from '@gitbook/api';
|
||||||
import { ContentKit, ContentKitOutput } from '@gitbook/react-contentkit';
|
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 { GitBookLinker } from '@/lib/links';
|
||||||
|
import type { BlockProps } from '../Block';
|
||||||
import {
|
import {
|
||||||
ContentKitWithClientContext,
|
ContentKitWithClientContext,
|
||||||
type WebframeLinkerData,
|
type WebframeLinkerData,
|
||||||
@@ -15,6 +15,9 @@ import { contentKitServerContext } from './contentkit';
|
|||||||
import { fetchSafeIntegrationUI } from './render';
|
import { fetchSafeIntegrationUI } from './render';
|
||||||
import { renderIntegrationUi } from './server-actions';
|
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>) {
|
export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegration>) {
|
||||||
const { block, context, style } = props;
|
const { block, context, style } = props;
|
||||||
|
|
||||||
@@ -102,6 +105,7 @@ export async function IntegrationBlock(props: BlockProps<DocumentBlockIntegratio
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tcls(style)}>
|
<div className={tcls(style)}>
|
||||||
|
<ContentKitStyles />
|
||||||
{useClientContext ? (
|
{useClientContext ? (
|
||||||
<ContentKitWithClientContext
|
<ContentKitWithClientContext
|
||||||
{...contentKitProps}
|
{...contentKitProps}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { tcls } from '@/lib/tailwind';
|
|||||||
|
|
||||||
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
import type { AnyOpenAPIOperationsBlock } from '@/lib/openapi/types';
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { getOpenAPIContext } from './context';
|
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an openapi block or an openapi-operation block.
|
* Render an openapi block or an openapi-operation block.
|
||||||
@@ -14,6 +14,7 @@ export async function OpenAPIOperation(props: BlockProps<AnyOpenAPIOperationsBlo
|
|||||||
const { style } = props;
|
const { style } = props;
|
||||||
return (
|
return (
|
||||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||||
|
<OpenAPIStyles />
|
||||||
<OpenAPIOperationBody {...props} />
|
<OpenAPIOperationBody {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { OpenAPISchemas as BaseOpenAPISchemas } from '@gitbook/react-openapi';
|
|||||||
|
|
||||||
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
import type { OpenAPISchemasBlock } from '@/lib/openapi/types';
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { getOpenAPIContext } from './context';
|
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an openapi-schemas block.
|
* Render an openapi-schemas block.
|
||||||
@@ -13,6 +13,7 @@ export async function OpenAPISchemas(props: BlockProps<OpenAPISchemasBlock>) {
|
|||||||
const { style } = props;
|
const { style } = props;
|
||||||
return (
|
return (
|
||||||
<div className={tcls('flex w-full', style, 'max-w-full')}>
|
<div className={tcls('flex w-full', style, 'max-w-full')}>
|
||||||
|
<OpenAPIStyles />
|
||||||
<OpenAPISchemasBody {...props} />
|
<OpenAPISchemasBody {...props} />
|
||||||
</div>
|
</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 { OpenAPIWebhookBlock } from '@/lib/openapi/types';
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { getOpenAPIContext } from './context';
|
import { OpenAPIStyles, getOpenAPIContext } from './context';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an openapi block or an openapi-webhook block.
|
* Render an openapi block or an openapi-webhook block.
|
||||||
@@ -14,6 +14,7 @@ export async function OpenAPIWebhook(props: BlockProps<OpenAPIWebhookBlock>) {
|
|||||||
const { style } = props;
|
const { style } = props;
|
||||||
return (
|
return (
|
||||||
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
<div className={tcls('flex w-full min-w-0', style, 'max-w-full')}>
|
||||||
|
<OpenAPIStyles />
|
||||||
<OpenAPIWebhookBody {...props} />
|
<OpenAPIWebhookBody {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { JSONDocument } from '@gitbook/api';
|
import type { JSONDocument } from '@gitbook/api';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import type { BlockProps } from '../Block';
|
import type { BlockProps } from '../Block';
|
||||||
import { PlainCodeBlock } from '../CodeBlock';
|
import { PlainCodeBlock } from '../CodeBlock';
|
||||||
import { DocumentView } from '../DocumentView';
|
import { DocumentView } from '../DocumentView';
|
||||||
import { Heading } from '../Heading';
|
import { Heading } from '../Heading';
|
||||||
|
|
||||||
import './style.css';
|
|
||||||
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
import { DEFAULT_LOCALE, getSpaceLocale } from '@/intl/server';
|
||||||
import type { GitBookAnyContext } from '@/lib/context';
|
import type { GitBookAnyContext } from '@/lib/context';
|
||||||
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
import { buildSignedProxyUrl } from '@/lib/openapi/proxy-token';
|
||||||
@@ -17,6 +17,12 @@ import type {
|
|||||||
OpenAPIWebhookBlock,
|
OpenAPIWebhookBlock,
|
||||||
} from '@/lib/openapi/types';
|
} 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.
|
* Get the OpenAPI context to render a block.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -116,52 +116,8 @@ button.openapi-mcp {
|
|||||||
@apply !mb-0;
|
@apply !mb-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Method Tags */
|
/* Method / status-code tag styles moved to `./tags.css`, loaded globally so the sidebar method
|
||||||
.openapi-method,
|
* badges are styled even before an OpenAPI block mounts this deferred stylesheet. */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* URL */
|
/* URL */
|
||||||
.openapi-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";
|
@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,
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
so we've added these compatibility styles to make sure everything still
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { t, useLanguage } from '@/intl/client';
|
import { t, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { AIChatButton } from '../AIChat';
|
import { AIChatButton } from '../AIChat';
|
||||||
@@ -10,13 +11,18 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
|||||||
import { Button, Popover } from '../primitives';
|
import { Button, Popover } from '../primitives';
|
||||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
import { SideSheet } from '../primitives/SideSheet';
|
import { SideSheet } from '../primitives/SideSheet';
|
||||||
import { SearchFrame } from './SearchFrame';
|
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
import { SearchLiveResultsAnnouncer } from './SearchLiveResultsAnnouncer';
|
||||||
import { SearchScopeControl } from './SearchScopeControl';
|
import { SearchScopeControl } from './SearchScopeControl';
|
||||||
import type { SearchBaseProps } from './search-props';
|
import type { SearchBaseProps } from './search-props';
|
||||||
import { useSearchController } from './useSearchController';
|
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 {
|
interface SearchContainerProps extends SearchBaseProps {
|
||||||
style: CustomizationSearchStyle;
|
style: CustomizationSearchStyle;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Document, type DocumentValue } from 'flexsearch';
|
import type { Document, DocumentValue } from 'flexsearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface Breadcrumb {
|
interface Breadcrumb {
|
||||||
@@ -63,8 +63,11 @@ const cachedPageData = new Map<
|
|||||||
|
|
||||||
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
|
let pendingFetch: Promise<Map<string, Document<IndexPage>>> | null = null;
|
||||||
|
|
||||||
function buildLangIndex(pages: RawIndexPage[]): Document<IndexPage> {
|
function buildLangIndex(
|
||||||
const index = new Document<IndexPage>({
|
DocumentCtor: typeof import('flexsearch').Document,
|
||||||
|
pages: RawIndexPage[]
|
||||||
|
): Document<IndexPage> {
|
||||||
|
const index = new DocumentCtor<IndexPage>({
|
||||||
document: {
|
document: {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
index: ['title', 'description'],
|
index: ['title', 'description'],
|
||||||
@@ -110,7 +113,9 @@ async function getOrBuildIndexes(indexURL: string): Promise<Map<string, Document
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingFetch = (async () => {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch search index: ${response.status}`);
|
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
|
// Build one FlexSearch Document per language group
|
||||||
for (const [lang, pages] of pagesByLang) {
|
for (const [lang, pages] of pagesByLang) {
|
||||||
cachedIndexes.set(lang, buildLangIndex(pages));
|
cachedIndexes.set(lang, buildLangIndex(Document, pages));
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedIndexes;
|
return cachedIndexes;
|
||||||
@@ -156,8 +161,11 @@ export function useLocalSearchResults(props: {
|
|||||||
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
|
* are returned. Uses FlexSearch native tag filtering. Omit for no filtering (all spaces). */
|
||||||
filterSiteSpaceIds?: string[];
|
filterSiteSpaceIds?: string[];
|
||||||
disabled?: boolean;
|
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 {
|
}): 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>({
|
const [state, setState] = React.useState<LocalSearchState>({
|
||||||
results: [],
|
results: [],
|
||||||
@@ -168,13 +176,17 @@ export function useLocalSearchResults(props: {
|
|||||||
// Track whether the indexes are loaded so the search effect re-runs after load
|
// Track whether the indexes are loaded so the search effect re-runs after load
|
||||||
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
|
const [indexReady, setIndexReady] = React.useState(cachedIndexes.size > 0);
|
||||||
|
|
||||||
// Load the indexes once
|
// Load the indexes once search becomes active (opened or queried).
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (cachedIndexes.size > 0) {
|
if (cachedIndexes.size > 0) {
|
||||||
setIndexReady(true);
|
setIndexReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setState((prev) => ({ ...prev, fetching: true, error: false }));
|
setState((prev) => ({ ...prev, fetching: true, error: false }));
|
||||||
|
|
||||||
@@ -194,7 +206,7 @@ export function useLocalSearchResults(props: {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [indexURL]);
|
}, [indexURL, active]);
|
||||||
|
|
||||||
// Perform instant local search whenever query, lang, or index readiness changes
|
// Perform instant local search whenever query, lang, or index readiness changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ export function useSearchController(props: SearchBaseProps) {
|
|||||||
const { results, fetching, error, abort } = useSearchResults({
|
const { results, fetching, error, abort } = useSearchResults({
|
||||||
asEmbeddable,
|
asEmbeddable,
|
||||||
disabled: !(state?.query || withAI),
|
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,
|
query: normalizedQuery,
|
||||||
siteSpaceId: siteSpace.id,
|
siteSpaceId: siteSpace.id,
|
||||||
siteSpaceIds,
|
siteSpaceIds,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const cachedRecommendedQuestions: Map<string, RecommendedQuestionResult[]> = new
|
|||||||
export function useSearchResults(props: {
|
export function useSearchResults(props: {
|
||||||
asEmbeddable?: boolean;
|
asEmbeddable?: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/** Whether the search surface is active (opened or has a query). Gates loading of the local index. */
|
||||||
|
active: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
siteSpaceId: string;
|
siteSpaceId: string;
|
||||||
siteSpaceIds: string[];
|
siteSpaceIds: string[];
|
||||||
@@ -59,6 +61,7 @@ export function useSearchResults(props: {
|
|||||||
const {
|
const {
|
||||||
asEmbeddable,
|
asEmbeddable,
|
||||||
disabled,
|
disabled,
|
||||||
|
active,
|
||||||
query,
|
query,
|
||||||
siteSpaceId,
|
siteSpaceId,
|
||||||
siteSpaceIds,
|
siteSpaceIds,
|
||||||
@@ -82,6 +85,7 @@ export function useSearchResults(props: {
|
|||||||
indexURL,
|
indexURL,
|
||||||
lang,
|
lang,
|
||||||
disabled,
|
disabled,
|
||||||
|
active,
|
||||||
filterSiteSpaceIds,
|
filterSiteSpaceIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ export async function SiteLayout(props: {
|
|||||||
ReactDOM.preconnect(GITBOOK_ASSETS_URL);
|
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 }) => {
|
scripts.forEach(({ script }) => {
|
||||||
ReactDOM.preload(script, {
|
ReactDOM.preload(script, {
|
||||||
as: 'script',
|
as: 'script',
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled';
|
|||||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIChatProvider } from '../AI';
|
|
||||||
import type { RenderAIMessageOptions } 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 { AIChat, AskAITextSelection } from '../AIChat';
|
||||||
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||||
import { Announcement } from '../Announcement';
|
import { Announcement } from '../Announcement';
|
||||||
@@ -88,9 +90,13 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
|||||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||||
>
|
>
|
||||||
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
||||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
{isAIChatEnabled(customization.ai?.mode) ? (
|
||||||
{children}
|
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||||
</AIChatProvider>
|
{children}
|
||||||
|
</AIChatProvider>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
</InsightsProvider>
|
</InsightsProvider>
|
||||||
</VisitorProvider>
|
</VisitorProvider>
|
||||||
</CurrentContentProvider>
|
</CurrentContentProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user