Update Docs Embed with new styling and tabs (#3823)

This commit is contained in:
Zeno Kapitein
2025-12-04 10:01:25 +01:00
committed by GitHub
parent 3fbd6f0d50
commit 1430ceebea
32 changed files with 1045 additions and 236 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/embed": minor
"gitbook": patch
---
Improve Docs Embed with separate Assistant and Docs tabs
+370 -8
View File
@@ -1,24 +1,61 @@
# `@gitbook/embed` # GitBook Docs Embed (`@gitbook/embed`)
Embed the GitBook Docs Assistant in your product or website. Embed your GitBook docs in your product or website.
The Docs Embed can contain two tabs:
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
- **Docs**: A browser for navigating your documentation site
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
# Usage # Usage
## As a script from your docs site ## As a standalone script from your docs site
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website. All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website.
The script is served at `https://docs.company.com/~gitbook/embed/script.js`. The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname. You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
```html ```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script> <script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script> <script>
// Initialize with Authenticated Access (optional)
window.GitBook('init',
{ siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } }
);
window.GitBook('show'); window.GitBook('show');
</script> </script>
``` ```
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
### Example: Configuring the widget
```javascript
GitBook('configure', {
button: {
label: 'Ask',
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
},
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
```
See the [Configuration](#configuration) section for all available options.
## As a package from NPM ## As a package from NPM
Install the package: `npm install @gitbook/embed` and import it in your web application: Install the package: `npm install @gitbook/embed` and import it in your web application:
@@ -30,10 +67,46 @@ const gitbook = createGitBook({
siteURL: 'https://docs.company.com' siteURL: 'https://docs.company.com'
}); });
// Create an iframe and get its URL
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL(); iframe.src = gitbook.getFrameURL({
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
userId: '123',
plan: 'premium'
}
}
});
// Create a frame client to communicate with the iframe
const frame = gitbook.createFrame(iframe); const frame = gitbook.createFrame(iframe);
// Use the frame client methods
frame.navigateToPage('/getting-started'); // Navigate to a page in the docs tab
frame.navigateToAssistant(); // Switch to the assistant tab
frame.postUserMessage('How do I get started?');
frame.clearChat();
// Configure the embed (see Configuration section for all options)
frame.configure({
tabs: ['assistant', 'docs'],
actions: [
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
],
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
suggestions: ['What is GitBook?', 'How do I get started?'],
tools: [/* ... */]
});
// Listen to events
frame.on('close', () => {
console.log('Frame closed');
});
``` ```
## As React components ## As React components
@@ -41,9 +114,298 @@ const frame = gitbook.createFrame(iframe);
After installing the NPM package, you can import prebuilt React components: After installing the NPM package, you can import prebuilt React components:
```tsx ```tsx
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react'; import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com"> <GitBookProvider siteURL="https://docs.company.com">
<GitBookAssistantFrame /> <GitBookFrame
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
}}
tabs={['assistant', 'docs']}
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
suggestions={['What is GitBook?', 'How do I get started?']}
actions={[
{
icon: 'circle-question',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
}
]}
tools={[/* ... */]}
/>
</GitBookProvider> </GitBookProvider>
``` ```
You can also use the `useGitBook` hook to access the client:
```tsx
import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
// ...
}
```
# API Reference
## Method Comparison
| Method | Standalone Script | NPM Package | React Components |
|--------|------------------|-------------|------------------|
| **Initialize** | `GitBook('init', options, frameOptions)` | `createGitBook(options)` | `<GitBookProvider siteURL="...">` |
| **Get frame URL** | ❌ (handled internally) | `client.getFrameURL(options)` | `useGitBook().getFrameURL(options)` |
| **Create frame client** | ❌ (handled internally) | `client.createFrame(iframe)` | `useGitBook().createFrame(iframe)` |
| **Show/Hide widget** | `GitBook('show')` / `GitBook('hide')` | ❌ | ❌ |
| **Open/Close window** | `GitBook('open')` / `GitBook('close')` / `GitBook('toggle')` | ❌ | ❌ |
| **Navigate to page** | `GitBook('navigateToPage', path)` | `frame.navigateToPage(path)` | Via frame client |
| **Navigate to assistant** | `GitBook('navigateToAssistant')` | `frame.navigateToAssistant()` | Via frame client |
| **Post message** | `GitBook('postUserMessage', message)` | `frame.postUserMessage(message)` | Via frame client |
| **Clear chat** | `GitBook('clearChat')` | `frame.clearChat()` | Via frame client |
| **Configure** | `GitBook('configure', settings)` | `frame.configure(settings)` | Props on `<GitBookFrame>` |
| **Event listeners** | ❌ | `frame.on(event, listener)` | Via frame client |
| **Unload** | `GitBook('unload')` | ❌ | ❌ |
## Method Signatures
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
- `GitBook('close')` - Close widget window
- `GitBook('toggle')` - Toggle widget window
- `GitBook('navigateToPage', path: string)` - Navigate to page
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
- `GitBook('postUserMessage', message: string)` - Post message to chat
- `GitBook('clearChat')` - Clear chat history
- `GitBook('configure', settings: {...})` - Configure widget
- `GitBook('unload')` - Unload widget
### NPM Package
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
- `frame.navigateToPage(path: string)``void`
- `frame.navigateToAssistant()``void`
- `frame.postUserMessage(message: string)``void`
- `frame.clearChat()``void`
- `frame.configure(settings: Partial<GitBookEmbeddableConfiguration>)``void`
- `frame.on(event: string, listener: Function)``() => void` (unsubscribe)
### React Components
**Components:**
- `<GitBookProvider siteURL: string>` - Provider component
- `<GitBookFrame {...props}>` - Frame component (accepts all config options as props)
**Hooks:**
- `useGitBook()``GitBookClient` (must be used within `<GitBookProvider>`)
# Configuration
Configuration options are available across usage methods as follows:
- **Standalone script**: via `GitBook('configure', {...})`
- **NPM package**: via `frame.configure({...})`
- **React components**: via props on `<GitBookFrame>`
### `tabs`
Available in: Standalone script, NPM package, React components
Override which tabs are displayed. Defaults to your site's configuration.
- **Type**: `('assistant' | 'docs')[]`
- **Options**:
- `['assistant', 'docs']` - Show both tabs
- `['assistant']` - Show only the assistant tab
- `['docs']` - Show only the docs tab
```javascript
tabs: ['assistant', 'docs']
```
### `actions`
Available in: Standalone script, NPM package, React components
Custom action buttons rendered in the sidebar alongside tabs. Each action button triggers a callback when clicked.
**Note**: This prop was previously named `buttons`. Use `actions` instead, it has the same functionality.
- **Type**: `GitBookEmbeddableActionDefinition[]`
- **Properties**:
- `icon`: `string` - Icon name. Any [FontAwesome icon](https://fontawesome.com/search) is supported. (e.g., `'rocket'`, `'comments'`, `'user-circle'`, ...)
- `label`: `string` - Button label text
- `onClick`: `() => void | Promise<void>` - Callback function when clicked
```javascript
actions: [
{
icon: 'comments',
label: 'Contact Support',
onClick: () => window.open('https://support.example.com', '_blank')
},
{
icon: 'rocket',
label: 'Get started',
onClick: () => {
GitBook('navigateToPage', '/getting-started');
}
}
]
```
### `greeting`
Available in: Standalone script, NPM package, React components
Welcome message displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) tab.
- **Type**: `{ title: string, subtitle: string }`
```javascript
greeting: {
title: 'Welcome!',
subtitle: 'How can I help you today?'
}
```
### `suggestions`
Available in: Standalone script, NPM package, React components
Suggested questions displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) welcome screen.
- **Type**: `string[]`
```javascript
suggestions: [
'What is GitBook?',
'How do I get started?',
'What can you do?'
]
```
### `tools`
Available in: Standalone script, NPM package, React components
Custom AI tools to extend the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant). Tools allow the assistant to execute functions and integrate with your own systems.
**Note**: In addition to custom tools, the Assistant will always have access to any [MCP servers you define](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant#extend-gitbook-assistant-with-mcp-servers) in your site's AI settings.
- **Type**: `GitBookToolDefinition[]`
- **Properties**:
- `name`: `string` - Unique tool identifier
- `description`: `string` - Description of what the tool does (used by the AI to decide when and how to use it).
- `inputSchema`: `object` - JSON schema defining the tool's input parameters
- `execute`: `(input: object) => Promise<{ output: any, summary: string }>` - Async function that executes the tool.
- `output`: The result of the tool execution, provided to the AI to continue working with. Not shown to the user.
- `summary`: The visual summary of the tool execution, shown in the user's chat window.
- `confirmation`: `{ icon?: string, label: string }` (optional) - Confirmation button shown before execution, useful for actions that require the user's express approval.
```javascript
tools: [
{
name: 'get_user_info',
description: 'Get information about the current user',
inputSchema: {
type: 'object',
properties: {
userId: {
type: 'string',
description: 'The user ID to look up'
}
},
required: ['userId']
},
execute: async (input) => {
const user = await fetch(`/api/users/${input.userId}`).then(r => r.json());
return {
output: { name: user.name, plan: user.plan },
summary: `Retrieved info for user ${user.name}`
};
}
},
{
name: 'create_ticket',
description: 'Create a support ticket',
confirmation: {
icon: 'circle-question',
label: 'Create support ticket?'
},
inputSchema: {
type: 'object',
properties: {
subject: { type: 'string' },
description: { type: 'string' }
},
required: ['subject', 'description']
},
execute: async (input) => {
const ticket = await fetch('/api/tickets', {
method: 'POST',
body: JSON.stringify(input)
}).then(r => r.json());
return {
output: { ticketId: ticket.id },
summary: `Created ticket #${ticket.id}`
};
}
}
]
```
### `visitor` (Authenticated Access)
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
[Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access) options passed when creating the frame URL. Used for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) and [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access).
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ visitor: {...} })`
**React components**: Pass as the `visitor` prop on `<GitBookFrame>`
- **Type**: `{ token?: string, unsignedClaims?: Record<string, unknown> }`
- **Properties**:
- `token`: `string` (optional) - Signed JWT token for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) or [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access)
- `unsignedClaims`: `Record<string, unknown>` (optional) - Unsigned claims that can be used in dynamic expressions via `visitor.claims.unsigned.<claim-name>`
```javascript
visitor: {
token: 'your-jwt-token',
unsignedClaims: {
userId: '123',
plan: 'premium',
role: 'admin'
}
}
```
### `button`
Available in: Standalone script only
Configure the widget button for the standalone script. This option is not available when using the NPM package or React components, since they can be customized completely.
- **Type**: `{ label: string, icon: 'assistant' | 'sparkle' | 'help' | 'book' }`
- **Properties**:
- `label`: `string` - Button label text
- `icon`: `'assistant' | 'sparkle' | 'help' | 'book'` - Icon displayed on the button. Choose from one of 4 presets.
```javascript
button: {
label: 'Ask',
icon: 'assistant'
}
```
+1 -1
View File
@@ -40,7 +40,7 @@ export function createGitBook(options: CreateGitBookOptions) {
const client: GitBookClient = { const client: GitBookClient = {
getFrameURL: (frameOptions) => { getFrameURL: (frameOptions) => {
const url = new URL(options.siteURL); const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`; url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.visitor?.token) { if (frameOptions.visitor?.token) {
url.searchParams.set('token', frameOptions.visitor.token); url.searchParams.set('token', frameOptions.visitor.token);
@@ -64,8 +64,9 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const events = new Map<string, Array<(...args: any[]) => void>>(); const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = { const configuration: GitBookEmbeddableConfiguration = {
buttons: [], tabs: ['assistant', 'docs'],
welcomeMessage: '', actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [], suggestions: [],
tools: [], tools: [],
}; };
+15 -5
View File
@@ -23,7 +23,7 @@ export type GitBookToolDefinition = AIToolDefinition & {
/** /**
* Custom button definition to be passed to the embeddable GitBook. * Custom button definition to be passed to the embeddable GitBook.
*/ */
export type GitBookEmbeddableButtonDefinition = { export type GitBookEmbeddableActionDefinition = {
/** /**
* Icon to be displayed in the button. * Icon to be displayed in the button.
*/ */
@@ -41,16 +41,26 @@ export type GitBookEmbeddableButtonDefinition = {
}; };
/** /**
* Overall configuration for the layout of the embeddable GitBook. * Overall configuration for the layout of the GitBook embed.
*/ */
export type GitBookEmbeddableConfiguration = { export type GitBookEmbeddableConfiguration = {
/** Tabs to display in the embed (if enabled on the site). */
tabs: ('assistant' | 'docs')[];
/** Additional buttons to be displayed in the header of the GitBook embed. */
actions: GitBookEmbeddableActionDefinition[];
/** /**
* Buttons to be displayed in the header of the embeddable GitBook. * Additional buttons to be displayed in the header of the GitBook embed.
* @deprecated Use `actions` instead.
*/ */
buttons: GitBookEmbeddableButtonDefinition[]; buttons?: GitBookEmbeddableActionDefinition[];
/** Message to be displayed in the welcome page. */ /** Message to be displayed in the welcome page. */
welcomeMessage: string; greeting: {
title: string;
subtitle: string;
};
/** Suggestions of questions to be displayed in the welcome page. */ /** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[]; suggestions: string[];
+5 -4
View File
@@ -17,7 +17,7 @@ export type GitBookFrameProps = {
* Render a frame with the GitBook Assistant in it. * Render a frame with the GitBook Assistant in it.
*/ */
export function GitBookFrame(props: GitBookFrameProps) { export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props; const { className, visitor, actions, greeting, suggestions, tools } = props;
const frameRef = useRef<HTMLIFrameElement>(null); const frameRef = useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook(); const gitbook = useGitBook();
@@ -33,12 +33,13 @@ export function GitBookFrame(props: GitBookFrameProps) {
useEffect(() => { useEffect(() => {
gitbookFrame?.configure({ gitbookFrame?.configure({
buttons, tabs: ['assistant', 'docs'],
welcomeMessage, actions,
greeting,
suggestions, suggestions,
tools, tools,
}); });
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]); }, [gitbookFrame, actions, greeting, suggestions, tools]);
return ( return (
<iframe <iframe
+47 -28
View File
@@ -31,24 +31,50 @@ type StandaloneCalls =
// Clear the chat // Clear the chat
| ['clearChat'] | ['clearChat']
// Configure the embed // Configure the embed
| ['configure', Partial<GitBookEmbeddableConfiguration>] | ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>]
// Navigate to a page // Navigate to a page
| ['navigateToPage', string] | ['navigateToPage', string]
// Navigate to the assistant // Navigate to the assistant
| ['navigateToAssistant']; | ['navigateToAssistant'];
type StandaloneConfiguration = {
/** Configure the button to open the embed */
button: {
/** Label to be displayed in the button. */
label: string;
/** Icon to be displayed in the button. */
icon: 'assistant' | 'sparkle' | 'help' | 'book';
};
};
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & { export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
q?: StandaloneCalls[]; q?: StandaloneCalls[];
}; };
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
icon: 'assistant',
},
actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [],
tools: [],
tabs: ['assistant', 'docs'],
};
const widgetButton = document.createElement('button'); const widgetButton = document.createElement('button');
widgetButton.id = 'gitbook-widget-button'; widgetButton.id = 'gitbook-widget-button';
widgetButton.addEventListener('click', () => { widgetButton.addEventListener('click', () => {
GitBook('toggle'); GitBook('toggle');
}); });
widgetButton.innerHTML = ` widgetButton.innerHTML = `
<span id="gitbook-widget-button-icon"></span> <span id="gitbook-widget-button-icon" data-icon="${frameConfiguration.button.icon}"></span>
<span id="gitbook-widget-button-label">Ask</span> <span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span>
`; `;
const widgetWindow = document.createElement('div'); const widgetWindow = document.createElement('div');
@@ -58,17 +84,6 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton); document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow); document.body.appendChild(widgetWindow);
let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
function getClient() { function getClient() {
if (!_client) { if (!_client) {
throw new Error( throw new Error(
@@ -135,27 +150,31 @@ const GitBook = (...args: StandaloneCalls) => {
case 'postUserMessage': case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]); getIframe().frame.postUserMessage(args[1]);
break; break;
case 'configure': case 'configure': {
const settings = args[1];
frameConfiguration = { frameConfiguration = {
...frameConfiguration, ...frameConfiguration,
...args[1], ...settings,
}; };
// Update the button label and icon
if (settings.button?.label) {
const label = widgetButton.querySelector('#gitbook-widget-button-label');
if (label) {
label.textContent = settings.button.label;
}
}
if (settings.button?.icon) {
const icon = widgetButton.querySelector('#gitbook-widget-button-icon');
if (icon) {
icon.setAttribute('data-icon', settings.button.icon);
}
}
getIframe().frame.configure({ getIframe().frame.configure({
...frameConfiguration, ...frameConfiguration,
buttons: [
...frameConfiguration.buttons,
// Always include a close button
{
icon: 'close',
label: 'Close',
onClick: () => {
GitBook('close');
},
},
],
}); });
break; break;
}
case 'clearChat': case 'clearChat':
getIframe().frame.clearChat(); getIframe().frame.clearChat();
break; break;
+12
View File
@@ -113,6 +113,18 @@
background-color: currentColor; background-color: currentColor;
} }
#gitbook-widget-button-icon[data-icon="sparkle"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/sparkle.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="help"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/circle-question.svg?v=2&token=a463935e93");
}
#gitbook-widget-button-icon[data-icon="book"] {
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/book-open.svg?v=2&token=a463935e93");
}
#gitbook-widget-button.open #gitbook-widget-button-icon { #gitbook-widget-button.open #gitbook-widget-button-icon {
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93'); mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
} }
@@ -1,7 +1,28 @@
import type { RouteLayoutParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable'; import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export const dynamic = 'force-static'; export const dynamic = 'force-static';
export default async function Page() { export default async function Page(props: PageProps) {
return <EmbeddableAssistantPage />; const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
// If the assistant is not enabled, redirect to the docs
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
}
return (
<EmbeddableAssistantPage
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
);
} }
@@ -0,0 +1,21 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableDynamicContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// If assistant is enabled, redirect to assistant, otherwise to docs
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
redirect(`${baseURL}/assistant`);
} else {
redirect(`${baseURL}/page/`);
}
}
@@ -1,7 +1,28 @@
import type { RouteParams } from '@/app/utils';
import { EmbeddableAssistantPage } from '@/components/Embeddable'; import { EmbeddableAssistantPage } from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
export default async function Page() { type PageProps = {
return <EmbeddableAssistantPage />; params: Promise<RouteParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
// If the assistant is not enabled, redirect to the docs
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
}
return (
<EmbeddableAssistantPage
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
siteTitle={context.site.title}
/>
);
} }
@@ -0,0 +1,23 @@
import type { RouteLayoutParams } from '@/app/utils';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { CustomizationAIMode } from '@gitbook/api';
import { redirect } from 'next/navigation';
export const dynamic = 'force-static';
type PageProps = {
params: Promise<RouteLayoutParams>;
};
export default async function Page(props: PageProps) {
const params = await props.params;
const { context } = await getEmbeddableStaticContext(params);
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
// If assistant is enabled, redirect to assistant, otherwise to docs
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
redirect(`${baseURL}/assistant`);
} else {
redirect(`${baseURL}/page/`);
}
}
@@ -88,6 +88,7 @@ export function useAI(): AIContext {
<AIChatIcon <AIChatIcon
state={chat.loading ? 'thinking' : 'default'} state={chat.loading ? 'thinking' : 'default'}
trademark={config.trademark} trademark={config.trademark}
className="size-4"
/> />
), ),
open: (query?: string) => { open: (query?: string) => {
@@ -87,6 +87,19 @@ export type AIChatState = {
error: boolean; error: boolean;
}; };
export type AIChatEvent =
| { type: 'open' }
| { type: 'postMessage'; message: string }
| { type: 'clear' }
| { type: 'close' };
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
Extract<AIChatEvent, { type: T }>,
'type'
>;
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = { export type AIChatController = {
/** Open the dialog */ /** Open the dialog */
open: () => void; open: () => void;
@@ -96,6 +109,11 @@ export type AIChatController = {
postMessage: (input: { message: string }) => void; postMessage: (input: { message: string }) => void;
/** Clear the conversation */ /** Clear the conversation */
clear: () => void; clear: () => void;
/** Register an event listener */
on: <T extends AIChatEvent['type']>(
event: T,
listener: (input?: AIChatEventData<T>) => void
) => () => void;
}; };
const AIChatControllerContext = React.createContext<AIChatController | null>(null); const AIChatControllerContext = React.createContext<AIChatController | null>(null);
@@ -123,6 +141,17 @@ export function useAIChatState(): AIChatState {
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. * Provide the controller to interact with the AI chat.
*/ */
@@ -137,6 +166,9 @@ export function AIChatProvider(props: {
const [, setSearchState] = useSearch(); const [, setSearchState] = useSearch();
const language = useLanguage(); const language = useLanguage();
// Event listeners storage
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state // Open AI chat and sync with search state
const onOpen = React.useCallback(() => { const onOpen = React.useCallback(() => {
const { initialQuery } = globalState.getState(); const { initialQuery } = globalState.getState();
@@ -149,6 +181,8 @@ export function AIChatProvider(props: {
scope: prev?.scope ?? 'default', scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat open: false, // Close search popover when opening chat
})); }));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]); }, [setSearchState]);
// Close AI chat and clear ask parameter // Close AI chat and clear ask parameter
@@ -162,6 +196,8 @@ export function AIChatProvider(props: {
scope: prev?.scope ?? 'default', scope: prev?.scope ?? 'default',
open: false, open: false,
})); }));
notify(eventsRef.current.get('close'), {});
}, [setSearchState]); }, [setSearchState]);
// Stream a message with the AI backend // Stream a message with the AI backend
@@ -379,8 +415,14 @@ export function AIChatProvider(props: {
})); }));
} }
notify(eventsRef.current.get('postMessage'), { message: input.message });
if (query === input.message) { if (query === input.message) {
// Return early if the message is the same as the previous message // Return early if the message is the same as the previous message
globalState.setState((state) => ({
...state,
opened: true,
}));
return; return;
} }
@@ -440,14 +482,34 @@ export function AIChatProvider(props: {
})); }));
}, [setSearchState]); }, [setSearchState]);
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(() => { const controller = React.useMemo(() => {
return { return {
open: onOpen, open: onOpen,
close: onClose, close: onClose,
clear: onClear, clear: onClear,
postMessage: onPostMessage, postMessage: onPostMessage,
on: onEvent,
}; };
}, [onOpen, onClose, onClear, onPostMessage]); }, [onOpen, onClose, onClear, onPostMessage, onEvent]);
return ( return (
<AIChatControllerContext.Provider value={controller}> <AIChatControllerContext.Provider value={controller}>
@@ -19,6 +19,7 @@ import {
EmbeddableFrameButtons, EmbeddableFrameButtons,
EmbeddableFrameHeader, EmbeddableFrameHeader,
EmbeddableFrameHeaderMain, EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSubtitle, EmbeddableFrameSubtitle,
EmbeddableFrameTitle, EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame'; } from '../Embeddable/EmbeddableFrame';
@@ -78,29 +79,31 @@ export function AIChat() {
)} )}
> >
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96"> <EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
<EmbeddableFrameHeader> <EmbeddableFrameMain>
<AIChatDynamicIcon trademark={config.trademark} /> <EmbeddableFrameHeader>
<EmbeddableFrameHeaderMain> <AIChatDynamicIcon trademark={config.trademark} />
<EmbeddableFrameTitle> <EmbeddableFrameHeaderMain>
{getAIChatName(language, config.trademark)} <EmbeddableFrameTitle>
</EmbeddableFrameTitle> {getAIChatName(language, config.trademark)}
<AIChatSubtitle chat={chat} /> </EmbeddableFrameTitle>
</EmbeddableFrameHeaderMain> <AIChatSubtitle chat={chat} />
<EmbeddableFrameButtons> </EmbeddableFrameHeaderMain>
<AIChatControlButton /> <EmbeddableFrameButtons>
<Button <AIChatControlButton />
onClick={() => chatController.close()} <Button
iconOnly onClick={() => chatController.close()}
icon="close" iconOnly
label={tString(language, 'close')} icon="close"
variant="blank" label={tString(language, 'close')}
size="default" variant="blank"
/> size="default"
</EmbeddableFrameButtons> />
</EmbeddableFrameHeader> </EmbeddableFrameButtons>
<EmbeddableFrameBody> </EmbeddableFrameHeader>
<AIChatBody chatController={chatController} chat={chat} /> <EmbeddableFrameBody>
</EmbeddableFrameBody> <AIChatBody chatController={chatController} chat={chat} />
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame> </EmbeddableFrame>
</div> </div>
); );
@@ -111,13 +114,14 @@ export function AIChat() {
*/ */
export function AIChatDynamicIcon(props: { export function AIChatDynamicIcon(props: {
trademark: boolean; trademark: boolean;
className?: string;
}) { }) {
const { trademark } = props; const { trademark, className } = props;
const chat = useAIChatState(); const chat = useAIChatState();
return ( return (
<AIChatIcon <AIChatIcon
className="size-5 text-tint" className={tcls('size-5 text-tint', className)}
trademark={trademark} trademark={trademark}
state={ state={
chat.error chat.error
@@ -1,10 +1,9 @@
'use client'; 'use client';
import { useLanguage } from '@/intl/client'; import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate'; import { t } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '../AI'; import { useAIChatController, useAIChatState } from '../AI';
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives'; import { Button } from '../primitives';
/** /**
* Button to control the chat (clear, etc.) * Button to control the chat (clear, etc.)
@@ -15,26 +14,16 @@ export function AIChatControlButton() {
const chatController = useAIChatController(); const chatController = useAIChatController();
return chat.messages.length > 0 ? ( return chat.messages.length > 0 ? (
<DropdownMenu <Button
button={ onClick={() => {
<Button chatController.clear();
onClick={() => {}} }}
iconOnly iconOnly
icon="ellipsis" icon="trash-can"
label={tString(language, 'actions')} label={t(language, 'ai_chat_clear_conversation')}
variant="blank" variant="blank"
size="default" size="default"
/> className="animate-blur-in-slow"
} />
>
<DropdownMenuItem
onClick={() => {
chatController.clear();
}}
>
<Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
{t(language, 'ai_chat_clear_conversation')}
</DropdownMenuItem>
</DropdownMenu>
) : null; ) : null;
} }
@@ -18,20 +18,36 @@ import {
EmbeddableFrameButtons, EmbeddableFrameButtons,
EmbeddableFrameHeader, EmbeddableFrameHeader,
EmbeddableFrameHeaderMain, EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSidebar,
EmbeddableFrameTitle, EmbeddableFrameTitle,
} from './EmbeddableFrame'; } from './EmbeddableFrame';
import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI'; import {
EmbeddableIframeButtons,
EmbeddableIframeTabs,
useEmbeddableConfiguration,
} from './EmbeddableIframeAPI';
type EmbeddableAIChatProps = {
baseURL: string;
siteTitle: string;
};
/** /**
* Embeddable AI chat window in an iframe. * Embeddable AI chat window in an iframe.
*/ */
export function EmbeddableAIChat() { export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const { baseURL, siteTitle } = props;
const chat = useAIChatState(); const chat = useAIChatState();
const { config } = useAI(); const { config } = useAI();
const chatController = useAIChatController(); const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration(); const configuration = useEmbeddableConfiguration();
const language = useLanguage(); const language = useLanguage();
React.useEffect(() => {
chatController.open();
}, [chatController]);
// Track the view of the AI chat // Track the view of the AI chat
const trackEvent = useTrackEvent(); const trackEvent = useTrackEvent();
React.useEffect(() => { React.useEffect(() => {
@@ -46,28 +62,45 @@ export function EmbeddableAIChat() {
); );
}, [trackEvent]); }, [trackEvent]);
const tabsRef = React.useRef<HTMLDivElement>(null);
return ( return (
<EmbeddableFrame> <EmbeddableFrame>
<EmbeddableFrameHeader> <EmbeddableFrameSidebar>
<AIChatDynamicIcon trademark={config.trademark} /> <EmbeddableIframeTabs
<EmbeddableFrameHeaderMain> ref={tabsRef}
<EmbeddableFrameTitle> active="assistant"
{getAIChatName(language, config.trademark)} baseURL={baseURL}
</EmbeddableFrameTitle> siteTitle={siteTitle}
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
/> />
</EmbeddableFrameBody> <EmbeddableIframeButtons />
</EmbeddableFrameSidebar>
<EmbeddableFrameMain>
<EmbeddableFrameHeader>
{!tabsRef.current ? (
<AIChatDynamicIcon
className="animate-blur-in-slow"
trademark={config.trademark}
/>
) : null}
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>
{getAIChatName(language, config.trademark)}
</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
/>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame> </EmbeddableFrame>
); );
} }
@@ -1,8 +1,13 @@
import { EmbeddableAIChat } from './EmbeddableAIChat'; import { EmbeddableAIChat } from './EmbeddableAIChat';
type EmbeddableAssistantPageProps = {
baseURL: string;
siteTitle: string;
};
/** /**
* Reusable page component for the embed assistant page. * Reusable page component for the embed assistant page.
*/ */
export async function EmbeddableAssistantPage() { export async function EmbeddableAssistantPage(props: EmbeddableAssistantPageProps) {
return <EmbeddableAIChat />; return <EmbeddableAIChat baseURL={props.baseURL} siteTitle={props.siteTitle} />;
} }
@@ -1,18 +1,24 @@
import { type PagePathParams, getSitePageData } from '@/components/SitePage'; import { type PagePathParams, getSitePageData } from '@/components/SitePage';
import { PageBody } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { SiteInsightsDisplayContext } from '@gitbook/api'; import { SiteInsightsDisplayContext } from '@gitbook/api';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Button } from '../primitives'; import { HeaderMobileMenu } from '../Header/HeaderMobileMenu';
import { PageBody } from '../PageBody';
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
import { TableOfContents } from '../TableOfContents';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { EmbeddableDocsPageControlButtons } from './EmbeddableDocsPageControlButtons';
import { import {
EmbeddableFrame, EmbeddableFrame,
EmbeddableFrameBody, EmbeddableFrameBody,
EmbeddableFrameButtons, EmbeddableFrameButtons,
EmbeddableFrameHeader, EmbeddableFrameHeader,
EmbeddableFrameHeaderMain, EmbeddableFrameHeaderMain,
EmbeddableFrameMain,
EmbeddableFrameSidebar,
EmbeddableFrameTitle,
} from './EmbeddableFrame'; } from './EmbeddableFrame';
import { EmbeddableIframeButtons } from './EmbeddableIframeAPI'; import { EmbeddableIframeButtons, EmbeddableIframeTabs } from './EmbeddableIframeAPI';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -33,32 +39,58 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
return ( return (
<EmbeddableFrame className="site-background"> <EmbeddableFrame className="site-background">
<EmbeddableFrameHeader> <EmbeddableFrameSidebar>
<EmbeddableFrameHeaderMain> <EmbeddableIframeTabs
<Button active="docs"
href={context.linker.toPathInSite('~gitbook/embed/assistant')} baseURL={context.linker.toPathInSite('~gitbook/embed/')}
size="default" siteTitle={context.site.title}
variant="blank" />
icon="arrow-left" <EmbeddableIframeButtons />
label="Back" </EmbeddableFrameSidebar>
/> <EmbeddableFrameMain>
</EmbeddableFrameHeaderMain> <div className="relative flex not-hydrated:animate-blur-in-slow flex-col">
<EmbeddableFrameButtons> <EmbeddableFrameHeader>
<EmbeddableIframeButtons /> <HeaderMobileMenu className="-ml-2 page-no-toc:hidden" />
</EmbeddableFrameButtons> <EmbeddableFrameHeaderMain>
</EmbeddableFrameHeader> <EmbeddableFrameTitle>{context.site.title}</EmbeddableFrameTitle>
<EmbeddableFrameBody> </EmbeddableFrameHeaderMain>
<div className="flex-1 overflow-auto p-6"> <EmbeddableFrameButtons>
<PageBody <EmbeddableDocsPageControlButtons
context={context} href={context.linker
page={page} .toPathForPage({
ancestors={ancestors} pages: context.revision.pages,
document={document} page,
withPageFeedback={withPageFeedback} })
insightsDisplayContext={SiteInsightsDisplayContext.Embed} .replace(/~gitbook\/embed\/page\/?/, '')}
/> />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
{context.sections ? (
<SiteSectionTabs
className="-mt-2 border-tint-subtle border-b"
sections={encodeClientSiteSections(context, context.sections)}
/>
) : null}
</div> </div>
</EmbeddableFrameBody> <EmbeddableFrameBody>
<ScrollContainer
orientation="vertical"
className="not-hydrated:animate-blur-in-slow"
contentClassName="p-4"
fadeEdges={context.sections ? [] : ['leading']}
>
<TableOfContents className="pt-0" context={context} />
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
insightsDisplayContext={SiteInsightsDisplayContext.Embed}
/>
</ScrollContainer>
</EmbeddableFrameBody>
</EmbeddableFrameMain>
</EmbeddableFrame> </EmbeddableFrame>
); );
} }
@@ -0,0 +1,21 @@
'use client';
import { tString, useLanguage } from '@/intl/client';
import { Button } from '../primitives';
export function EmbeddableDocsPageControlButtons(props: { href: string }) {
const { href } = props;
const language = useLanguage();
return (
<Button
icon="arrow-up-right-from-square"
label={tString(language, 'open_in_new_tab')}
href={href}
target="_blank"
iconOnly
variant="blank"
size="default"
/>
);
}
@@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
<div <div
{...divProps} {...divProps}
className={tcls( className={tcls(
'flex h-full grow flex-col overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint', 'flex h-full grow overflow-hidden bg-radial-[circle_at_bottom] from-primary to-50% to-transparent text-sm text-tint',
divProps.className divProps.className
)} )}
ref={ref} ref={ref}
@@ -28,13 +28,19 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
} }
); );
export function EmbeddableFrameMain(props: { children: React.ReactNode }) {
const { children } = props;
return <div className="flex flex-1 flex-col overflow-hidden">{children}</div>;
}
export function EmbeddableFrameHeader(props: { export function EmbeddableFrameHeader(props: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { children } = props; const { children } = props;
return ( return (
<div className="relative z-10 flex animate-fade-in-slow select-none items-center gap-2 px-4 py-2 text-tint-strong"> <div className="relative z-10 flex not-hydrated:animate-blur-in-slow select-none items-center gap-2 px-4 py-2.5 text-tint-strong">
{children} {children}
</div> </div>
); );
@@ -45,7 +51,7 @@ export function EmbeddableFrameHeaderMain(props: {
}) { }) {
const { children } = props; const { children } = props;
return <div className="flex flex-1 flex-col">{children}</div>; return <div className="flex h-8 flex-1 flex-col justify-center">{children}</div>;
} }
export function EmbeddableFrameBody(props: { export function EmbeddableFrameBody(props: {
@@ -82,10 +88,21 @@ export function EmbeddableFrameSubtitle(props: {
); );
} }
export function EmbeddableFrameButtons(props: { export function EmbeddableFrameSidebar(props: { children: React.ReactNode }) {
children: React.ReactNode;
}) {
const { children } = props; const { children } = props;
return <div className="-mr-2 ml-auto flex gap-2">{children}</div>; return (
<div className="flex w-13 shrink-0 origin-top not-hydrated:animate-blur-in-slow flex-col gap-2 overflow-hidden border-tint-solid/3 border-r bg-tint-solid/1 p-2 transition-all transition-discrete duration-300 empty:hidden empty:w-0 empty:px-0">
{children}
</div>
);
}
export function EmbeddableFrameButtons(props: {
className?: string;
children: React.ReactNode;
}) {
const { children, className } = props;
return <div className={tcls('flex gap-2', className)}>{children}</div>;
} }
@@ -4,15 +4,17 @@ import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitb
import { createChannel } from 'bidc'; import { createChannel } from 'bidc';
import React from 'react'; import React from 'react';
import { useAIChatController } from '@/components/AI'; import { useAI, useAIChatController } from '@/components/AI';
import { CustomizationAIMode } from '@gitbook/api';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand'; import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations'; import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives'; import { Button } from '../primitives';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
buttons: [], tabs: [],
welcomeMessage: '', actions: [],
greeting: { title: '', subtitle: '' },
suggestions: [], suggestions: [],
tools: [], tools: [],
})); }));
@@ -28,6 +30,12 @@ export function EmbeddableIframeAPI(props: {
const router = useRouter(); const router = useRouter();
const chatController = useAIChatController(); const chatController = useAIChatController();
React.useEffect(() => {
return chatController.on('open', () => {
router.push(`${baseURL}/assistant`);
});
}, [router, baseURL, chatController]);
React.useEffect(() => { React.useEffect(() => {
if (window.parent === window) { if (window.parent === window) {
return; return;
@@ -93,23 +101,114 @@ export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
* Display the buttons defined by the parent window. * Display the buttons defined by the parent window.
*/ */
export function EmbeddableIframeButtons() { export function EmbeddableIframeButtons() {
const buttons = useEmbeddableConfiguration((state) => state.buttons); const { actions: configuredActions, buttons: configuredButtons = [] } =
useEmbeddableConfiguration((state) => state);
const actions = configuredActions.length > 0 ? configuredActions : configuredButtons;
return ( return (
<> <>
{buttons.map((button) => ( {actions.length > 0 && (
<hr className="my-2 border-0 border-tint-subtle border-b first:hidden" />
)}
{actions.map((action, index) => (
<Button <Button
key={button.label} key={action.label}
size="default" size="default"
variant="blank" variant="blank"
icon={button.icon} icon={action?.icon ?? 'square-question'}
label={button.label} label={action?.label}
iconOnly iconOnly
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
disabled={!action.onClick}
onClick={() => { onClick={() => {
button.onClick(); action.onClick?.();
}} }}
tooltipProps={{
contentProps: {
side: 'right',
},
}}
style={{ animationDelay: `${index * 100}ms` }}
/> />
))} ))}
</> </>
); );
} }
export function EmbeddableIframeTabs(props: {
ref?: React.RefObject<HTMLDivElement | null>;
active?: string;
baseURL: string;
siteTitle: string;
}) {
const { ref, active = 'assistant', baseURL, siteTitle } = props;
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
const { assistants, config } = useAI();
const router = useRouter();
const tabs = [
config.aiMode === CustomizationAIMode.Assistant &&
assistants[0] &&
(configuredTabs.includes('assistant') || configuredTabs.length === 0)
? {
key: 'assistant',
label: assistants[0].label,
icon: assistants[0].icon,
onClick: () => {
router.push(`${baseURL}/assistant`);
},
}
: null,
configuredTabs.includes('docs') || configuredTabs.length === 0
? {
key: 'docs',
label: siteTitle,
icon: 'book-open',
onClick: () => {
router.push(`${baseURL}/page/`);
},
}
: null,
].filter((tab) => tab !== null);
// Override the active tab if it doesn't match the configured tabs
React.useEffect(() => {
const hasAssistant = tabs.find((tab) => tab.key === 'assistant');
const hasDocs = tabs.find((tab) => tab.key === 'docs');
if (!hasAssistant && !hasDocs) {
// No valid tabs, do not redirect
return;
}
if (active === 'assistant' && !hasAssistant) {
router.replace(`${baseURL}/page`);
} else if (active === 'docs' && !hasDocs) {
router.replace(`${baseURL}/assistant`);
}
}, [tabs, baseURL, router, active]);
return tabs.length > 1 || actions.length > 0 ? (
<div className="flex flex-col gap-2" ref={ref}>
{tabs.map((tab) => (
<Button
key={tab.key}
data-testid={`embed-tab-${tab.key}`}
label={tab.label}
size="default"
variant="blank"
icon={tab.icon}
active={tab.key === active}
className="not-hydrated:animate-blur-in-slow [&_.button-leading-icon]:size-5"
iconOnly
onClick={tab.onClick}
tooltipProps={{
contentProps: {
side: 'right',
},
}}
/>
))}
</div>
) : null;
}
@@ -7,8 +7,10 @@ import {
} from '@/components/SiteLayout'; } from '@/components/SiteLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive'; import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import { CustomizationAIMode } from '@gitbook/api'; import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout'; import { SpaceLayoutServerContext } from '../SpaceLayout';
import { TrademarkLink } from '../TableOfContents/Trademark';
import { NavigationLoader } from '../primitives/NavigationLoader';
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI'; import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
type EmbeddableRootLayoutProps = { type EmbeddableRootLayoutProps = {
@@ -29,12 +31,16 @@ export async function EmbeddableRootLayout({
return ( return (
<CustomizationRootLayout context={context}> <CustomizationRootLayout context={context}>
<SiteLayoutClientContexts <SiteLayoutClientContexts
forcedTheme={context.customization.themes.default} forcedTheme={
context.customization.themes.toggeable
? undefined
: context.customization.themes.default
}
externalLinksTarget={context.customization.externalLinks.target} externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId} contextId={context.contextId}
> >
<AIContextProvider <AIContextProvider
aiMode={CustomizationAIMode.Assistant} aiMode={context.customization.ai.mode}
trademark={context.customization.trademark.enabled} trademark={context.customization.trademark.enabled}
> >
<SpaceLayoutServerContext <SpaceLayoutServerContext
@@ -46,9 +52,19 @@ export async function EmbeddableRootLayout({
asEmbeddable: true, asEmbeddable: true,
}} }}
> >
<div className="fixed inset-0 flex flex-col">{children}</div> <NavigationLoader />
<div className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
<TrademarkLink
className="rounded-none border-tint-solid/3 border-t bg-tint-solid/1 px-4 py-2.5 text-tint/8 ring-0"
context={context}
placement={SiteInsightsTrademarkPlacement.Embed}
/>
) : null}
</div>
<EmbeddableIframeAPI <EmbeddableIframeAPI
baseURL={context.linker.toPathInSpace('~gitbook/embed/')} baseURL={context.linker.toPathInSite('~gitbook/embed/')}
/> />
</SpaceLayoutServerContext> </SpaceLayoutServerContext>
</AIContextProvider> </AIContextProvider>
@@ -1,13 +1,11 @@
'use client'; 'use client';
import { Icon } from '@gitbook/icons';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { tString, useLanguage } from '@/intl/client'; import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useScrollListener } from '../hooks/useScrollListener'; import { useScrollListener } from '../hooks/useScrollListener';
import { Button, type ButtonProps } from '../primitives';
const globalClassName = 'navigation-open'; const globalClassName = 'navigation-open';
@@ -16,18 +14,22 @@ const SCROLL_DISTANCE = 320;
/** /**
* Button to show/hide the table of content on mobile. * Button to show/hide the table of content on mobile.
*/ */
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) { export function HeaderMobileMenu(props: ButtonProps) {
const language = useLanguage(); const language = useLanguage();
const pathname = usePathname(); const pathname = usePathname();
const hasScrollRef = useRef(false); const hasScrollRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const toggleNavigation = () => { const toggleNavigation = () => {
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) { if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
document.body.classList.remove(globalClassName); document.body.classList.remove(globalClassName);
setIsOpen(false);
} else { } else {
document.body.classList.add(globalClassName); document.body.classList.add(globalClassName);
window.scrollTo(0, 0); window.scrollTo(0, 0);
setIsOpen(true);
} }
}; };
@@ -42,16 +44,15 @@ export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLB
}, [pathname]); }, [pathname]);
return ( return (
<button <Button
{...props} icon="bars"
aria-label={tString(language, 'table_of_contents_button_label')} iconOnly
variant="blank"
size="default"
label={tString(language, 'table_of_contents_button_label')}
onClick={toggleNavigation} onClick={toggleNavigation}
className={tcls( active={isOpen}
'flex flex-row items-center rounded-sm straight-corners:rounded-xs px-2 py-1', {...props}
props.className />
)}
>
<Icon icon="bars" className="size-4 text-inherit" />
</button>
); );
} }
@@ -33,6 +33,7 @@ export function SiteSectionTabs(props: {
children, children,
} = props; } = props;
const containerRef = React.useRef<HTMLDivElement>(null);
const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null); const currentTriggerRef = React.useRef<HTMLButtonElement | null>(null);
const [offset, setOffset] = React.useState<number | null>(null); const [offset, setOffset] = React.useState<number | null>(null);
const [value, setValue] = React.useState<string | undefined>(); const [value, setValue] = React.useState<string | undefined>();
@@ -41,12 +42,15 @@ export function SiteSectionTabs(props: {
React.useEffect(() => { React.useEffect(() => {
const trigger = currentTriggerRef.current; const trigger = currentTriggerRef.current;
if (!value || !trigger) { const container = containerRef.current;
if (!value || !trigger || !container) {
return; return;
} }
const triggerWidth = trigger.getBoundingClientRect().width; const triggerWidth = trigger.getBoundingClientRect().width - SCREEN_OFFSET;
const triggerLeft = trigger.getBoundingClientRect().left; const triggerLeft =
trigger.getBoundingClientRect().left -
(window.innerWidth - container.getBoundingClientRect().width) / 2;
setOffset(triggerLeft + triggerWidth / 2); setOffset(triggerLeft + triggerWidth / 2);
}, [value]); }, [value]);
@@ -58,6 +62,7 @@ export function SiteSectionTabs(props: {
'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]', 'page-default-width:2xl:px-[calc((100%-1536px+4rem)/2)]',
className className
)} )}
ref={containerRef}
value={value} value={value}
onValueChange={setValue} onValueChange={setValue}
skipDelayDuration={500} skipDelayDuration={500}
@@ -141,14 +146,14 @@ export function SiteSectionTabs(props: {
{children} {children}
<div <div
className="fixed top-full left-0 z-20 flex w-full" className="absolute top-full left-0 z-20 flex w-full"
style={{ style={{
padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`, padding: `0 ${SCREEN_OFFSET}px 0 ${SCREEN_OFFSET}px`,
}} }}
> >
<NavigationMenu.Viewport <NavigationMenu.Viewport
className={tcls( className={tcls(
'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg transition-transform duration-250 ease-in-out', 'relative origin-top overflow-auto circular-corners:rounded-3xl rounded-corners:rounded-xl border border-tint bg-tint-base shadow-lg ease-in-out',
'-mt-0.5 w-full md:w-max', '-mt-0.5 w-full md:w-max',
'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in', 'max-h-[calc(100vh-8rem)] data-[state=closed]:animate-scale-out data-[state=open]:animate-scale-in',
"[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready. "[&:not([style*='--radix-navigation-menu-viewport-width'])]:hidden" // The viewport width is only calculated once it's triggered, and can take a while. We hide the viewport until it's ready.
@@ -156,7 +161,7 @@ export function SiteSectionTabs(props: {
style={{ style={{
translate: translate:
!isMobile && offset !isMobile && offset
? `clamp(0px, calc(${offset}px - ${SCREEN_OFFSET}px - 50%), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0` ? `clamp(0px, calc(${offset}px - var(--radix-navigation-menu-viewport-width, 0px)/2), calc(100vw - var(--radix-navigation-menu-viewport-width, 0px) - ${SCREEN_OFFSET * 3}px)) 0 0`
: '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari : '0 0 0', // TranslateZ is needed to force a stacking context, fixing a rendering bug on Safari
display: offset === null ? 'none' : undefined, display: offset === null ? 'none' : undefined,
}} }}
@@ -13,8 +13,9 @@ export async function TableOfContents(props: {
context: GitBookSiteContext; context: GitBookSiteContext;
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list
className?: string;
}) { }) {
const { innerHeader, context, header } = props; const { innerHeader, context, header, className } = props;
const { customization, revision } = context; const { customization, revision } = context;
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages); const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
@@ -74,7 +75,8 @@ export async function TableOfContents(props: {
'gap-4', 'gap-4',
'navigation-open:border-b', 'navigation-open:border-b',
'border-tint-subtle' 'border-tint-subtle',
className
)} )}
> >
{header && header} {header && header}
@@ -13,7 +13,9 @@ import { Link } from '../primitives';
export function Trademark(props: { export function Trademark(props: {
context: GitBookSpaceContext; context: GitBookSpaceContext;
placement: SiteInsightsTrademarkPlacement; placement: SiteInsightsTrademarkPlacement;
className?: string;
}) { }) {
const { className, ...rest } = props;
return ( return (
<div <div
className={tcls( className={tcls(
@@ -55,10 +57,12 @@ export function Trademark(props: {
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle', '[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base', '[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base', '[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
'page-no-toc:before:to-transparent!' 'page-no-toc:before:to-transparent!',
className
)} )}
> >
<TrademarkLink {...props} /> <TrademarkLink {...rest} />
</div> </div>
); );
} }
@@ -69,8 +73,9 @@ export function Trademark(props: {
export function TrademarkLink(props: { export function TrademarkLink(props: {
context: GitBookSpaceContext; context: GitBookSpaceContext;
placement: SiteInsightsTrademarkPlacement; placement: SiteInsightsTrademarkPlacement;
className?: string;
}) { }) {
const { context, placement } = props; const { context, placement, className } = props;
const { space } = context; const { space } = context;
const language = getSpaceLanguage(context); const language = getSpaceLanguage(context);
@@ -85,9 +90,6 @@ export function TrademarkLink(props: {
href={url.toString()} href={url.toString()}
className={tcls( className={tcls(
'text-sm', 'text-sm',
// 'lg:max-xl:page-no-toc:text-xs',
// 'lg:max-xl:page-no-toc:px-3',
// 'lg:max-xl:page-no-toc:py-3',
'font-semibold', 'font-semibold',
'text-tint', 'text-tint',
@@ -113,29 +115,17 @@ export function TrademarkLink(props: {
'ring-tint-subtle', 'ring-tint-subtle',
'transition-colors', 'transition-colors',
'pointer-events-auto' 'pointer-events-auto',
className
)} )}
insights={{ insights={{
type: 'trademark_click', type: 'trademark_click',
placement, placement,
}} }}
> >
<Icon <Icon icon="gitbook" className={tcls('size-5', 'shrink-0')} />
icon="gitbook" <span className={tcls('ml-3')}>{t(language, 'powered_by_gitbook')}</span>
className={tcls(
'size-5',
// 'lg:max-xl:page-no-toc:size-4',
'shrink-0'
)}
/>
<span
className={tcls(
'ml-3'
// 'lg:max-xl:page-no-toc:ml-2'
)}
>
{t(language, 'powered_by_gitbook')}
</span>
</Link> </Link>
); );
} }
@@ -7,7 +7,7 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
import { Icon, type IconName } from '@gitbook/icons'; import { Icon, type IconName } from '@gitbook/icons';
import { Link, type LinkInsightsProps } from './Link'; import { Link, type LinkInsightsProps } from './Link';
import { useClassnames } from './StyleProvider'; import { useClassnames } from './StyleProvider';
import { Tooltip } from './Tooltip'; import { Tooltip, type TooltipProps } from './Tooltip';
export type ButtonProps = { export type ButtonProps = {
href?: string; href?: string;
@@ -20,6 +20,7 @@ export type ButtonProps = {
trailing?: React.ReactNode; trailing?: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
active?: boolean; active?: boolean;
tooltipProps?: TooltipProps;
} & LinkInsightsProps & } & LinkInsightsProps &
React.HTMLAttributes<HTMLElement>; React.HTMLAttributes<HTMLElement>;
@@ -112,6 +113,7 @@ export const Button = React.forwardRef<
active, active,
trailing, trailing,
disabled, disabled,
tooltipProps,
...rest ...rest
}, },
ref ref
@@ -133,18 +135,29 @@ export const Button = React.forwardRef<
); );
const buttonOnlyClassNames = useClassnames(['ButtonStyles']); const buttonOnlyClassNames = useClassnames(['ButtonStyles']);
let iconElement = null;
if (icon) {
if (React.isValidElement(icon)) {
type IconElement = React.ReactElement<React.SVGProps<SVGSVGElement>>;
iconElement = React.cloneElement(icon as IconElement, {
className: tcls(
'button-leading-icon size-[1em] shrink-0',
(icon as IconElement).props.className
),
});
} else {
iconElement = (
<Icon
icon={icon as IconName}
className={tcls('button-leading-icon size-[1em] shrink-0')}
/>
);
}
}
const content = ( const content = (
<> <>
{icon ? ( {iconElement}
typeof icon === 'string' ? (
<Icon
icon={icon as IconName}
className={tcls('button-leading-icon size-[1em] shrink-0')}
/>
) : (
icon
)
) : null}
{iconOnly || (!children && !label) ? null : ( {iconOnly || (!children && !label) ? null : (
<span className="button-content truncate">{children ?? label}</span> <span className="button-content truncate">{children ?? label}</span>
)} )}
@@ -184,9 +197,13 @@ export const Button = React.forwardRef<
return (children || iconOnly) && label ? ( return (children || iconOnly) && label ? (
<Tooltip <Tooltip
rootProps={{ open: disabled === true ? false : undefined }} rootProps={{
open: disabled === true ? false : undefined,
...tooltipProps?.rootProps,
}}
label={label} label={label}
triggerProps={{ disabled }} triggerProps={{ disabled, ...tooltipProps?.triggerProps }}
contentProps={{ ...tooltipProps?.contentProps }}
> >
{button} {button}
</Tooltip> </Tooltip>
@@ -78,8 +78,8 @@ export function Link(props: LinkProps) {
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => { const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
const isExternalWithOrigin = isExternalLink(href, window.location.origin); const isExternalWithOrigin = isExternalLink(href, window.location.origin);
// Only trigger navigation context for internal links without modifier keys (i.e. open in new tab). // Only trigger navigation context for internal links in the same window without modifier keys (i.e. open in new tab).
if (!isExternal && !event.ctrlKey && !event.metaKey) { if (!isExternal && target !== '_blank' && !event.ctrlKey && !event.metaKey) {
onNavigationClick(href); onNavigationClick(href);
} }
@@ -4,6 +4,12 @@ import { tcls } from '@/lib/tailwind';
import * as RadixTooltip from '@radix-ui/react-tooltip'; import * as RadixTooltip from '@radix-ui/react-tooltip';
import { useState } from 'react'; import { useState } from 'react';
export type TooltipProps = {
rootProps?: RadixTooltip.TooltipProps;
triggerProps?: RadixTooltip.TooltipTriggerProps;
contentProps?: RadixTooltip.TooltipContentProps;
};
export function Tooltip(props: { export function Tooltip(props: {
children: React.ReactNode; children: React.ReactNode;
label?: string | React.ReactNode; label?: string | React.ReactNode;
+11
View File
@@ -55,5 +55,16 @@ export function getEmbeddableLinker(linker: GitBookLinker): GitBookLinker {
spaceBasePath: joinPath(override.spaceBasePath, '~gitbook/embed/page'), spaceBasePath: joinPath(override.spaceBasePath, '~gitbook/embed/page'),
}); });
}, },
toLinkForContent(rawURL: string): string {
const result = linker.toLinkForContent(rawURL);
// If the link is not relative or already an embed, return it as is
if (result.includes('~gitbook/embed') || !result.startsWith('/')) {
return result;
}
// If the link is relative, assume it's a section link and append the embed path
return joinPath(result, '~gitbook/embed/page');
},
}; };
} }
+1
View File
@@ -612,6 +612,7 @@ function encodePathInSiteContent(rawPathname: string): {
} }
switch (pathname) { switch (pathname) {
case '~gitbook/embed':
case '~gitbook/embed/assistant': case '~gitbook/embed/assistant':
case '~gitbook/icon': case '~gitbook/icon':
return { pathname }; return { pathname };