Embeddable GitBook API (#3593)

This commit is contained in:
Samy Pessé
2025-08-26 14:08:24 +02:00
committed by GitHub
parent ada195d329
commit 81a6bd756a
19 changed files with 480 additions and 192 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": minor
---
Improve API to control the GitBook embed
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": minor
---
Support customization of buttons and tools through iframe API
+1
View File
@@ -18,6 +18,7 @@
"**/.vercel/**/*", "**/.vercel/**/*",
"**/.cache/**/*", "**/.cache/**/*",
"**/.wrangler/**/*", "**/.wrangler/**/*",
"packages/embed/standalone/**/*",
"packages/openapi-parser/src/fixtures/**/*", "packages/openapi-parser/src/fixtures/**/*",
"packages/emoji-codepoints/index.ts", "packages/emoji-codepoints/index.ts",
"packages/icons/src/data/*.json", "packages/icons/src/data/*.json",
+15 -10
View File
@@ -1,8 +1,7 @@
import { createChannel } from 'bidc'; import { createChannel } from 'bidc';
import type { import type {
FrameToParentMessage, FrameToParentMessage,
GitBookPlaceholderSettings, GitBookEmbeddableConfiguration,
GitBookToolDefinition,
ParentToFrameMessage, ParentToFrameMessage,
} from './protocol'; } from './protocol';
@@ -22,11 +21,6 @@ export type GitBookFrameClient = {
*/ */
postUserMessage: (message: string) => void; postUserMessage: (message: string) => void;
/**
* Register a custom tool.
*/
registerTool: (tool: GitBookToolDefinition) => void;
/** /**
* Clear the chat. * Clear the chat.
*/ */
@@ -35,7 +29,7 @@ export type GitBookFrameClient = {
/** /**
* Set the placeholder settings. * Set the placeholder settings.
*/ */
setPlaceholder: (placeholder: GitBookPlaceholderSettings) => void; configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
/** /**
* Register an event listener. * Register an event listener.
@@ -53,6 +47,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
const channel = createChannel(iframe.contentWindow); const channel = createChannel(iframe.contentWindow);
channel.receive((message: FrameToParentMessage) => { channel.receive((message: FrameToParentMessage) => {
console.log('[gitbook:embed] received message', message);
if (message.type === 'close') { if (message.type === 'close') {
const listeners = events.get('close') || []; const listeners = events.get('close') || [];
if (listeners) { if (listeners) {
@@ -62,11 +57,19 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
}); });
const sendToFrame = (message: ParentToFrameMessage) => { const sendToFrame = (message: ParentToFrameMessage) => {
console.log('[gitbook:embed] send message', message);
channel.send(message); channel.send(message);
}; };
const events = new Map<string, Array<(...args: any[]) => void>>(); const events = new Map<string, Array<(...args: any[]) => void>>();
const configuration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
return { return {
navigateToPage: (pagePath) => { navigateToPage: (pagePath) => {
sendToFrame({ type: 'navigateToPage', pagePath }); sendToFrame({ type: 'navigateToPage', pagePath });
@@ -75,9 +78,11 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
sendToFrame({ type: 'navigateToAssistant' }); sendToFrame({ type: 'navigateToAssistant' });
}, },
postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }), postUserMessage: (message) => sendToFrame({ type: 'postUserMessage', message }),
registerTool: (tool) => sendToFrame({ type: 'registerTool', tool }), configure: (settings) => {
Object.assign(configuration, settings);
sendToFrame({ type: 'configure', settings: configuration });
},
clearChat: () => sendToFrame({ type: 'clearChat' }), clearChat: () => sendToFrame({ type: 'clearChat' }),
setPlaceholder: (settings) => sendToFrame({ type: 'setPlaceholder', settings }),
on: (event, listener) => { on: (event, listener) => {
const listeners = events.get(event) || []; const listeners = events.get(event) || [];
listeners.push(listener); listeners.push(listener);
+31 -11
View File
@@ -21,18 +21,42 @@ export type GitBookToolDefinition = AIToolDefinition & {
}; };
/** /**
* Placeholder settings. * Custom button definition to be passed to the embeddable GitBook.
*/ */
export type GitBookPlaceholderSettings = { export type GitBookEmbeddableButtonDefinition = {
/** /**
* Welcome message to be displayed in the placeholder. * Icon to be displayed in the button.
*/ */
welcomeMessage: string; icon: IconName;
/** /**
* Suggestions to be displayed in the placeholder. * Label to be displayed in the button.
*/ */
label: string;
/**
* Callback when the button is clicked.
*/
onClick: () => void | Promise<void>;
};
/**
* Overall configuration for the layout of the embeddable GitBook.
*/
export type GitBookEmbeddableConfiguration = {
/**
* Buttons to be displayed in the header of the embeddable GitBook.
*/
buttons: GitBookEmbeddableButtonDefinition[];
/** Message to be displayed in the welcome page. */
welcomeMessage: string;
/** Suggestions of questions to be displayed in the welcome page. */
suggestions: string[]; suggestions: string[];
/** Tools to be provided to the assistant. */
tools: GitBookToolDefinition[];
}; };
/** /**
@@ -43,16 +67,12 @@ export type ParentToFrameMessage =
type: 'postUserMessage'; type: 'postUserMessage';
message: string; message: string;
} }
| {
type: 'registerTool';
tool: GitBookToolDefinition;
}
| { | {
type: 'clearChat'; type: 'clearChat';
} }
| { | {
type: 'setPlaceholder'; type: 'configure';
settings: GitBookPlaceholderSettings; settings: GitBookEmbeddableConfiguration;
} }
| { | {
type: 'navigateToPage'; type: 'navigateToPage';
@@ -1,38 +0,0 @@
import React from 'react';
import type { GetFrameURLOptions, GitBookFrameClient } from '../client';
import { useGitBook } from './GitBookProvider';
export type GitBookAssistantFrameProps = {
title?: string;
className?: string;
} & GetFrameURLOptions;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookAssistantFrame(props: GitBookAssistantFrameProps) {
const { title, className, ...frameOptions } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbookFrameRef = React.useRef<GitBookFrameClient | null>(null);
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL(frameOptions);
React.useEffect(() => {
if (frameRef.current) {
gitbookFrameRef.current = gitbook.createFrame(frameRef.current);
}
}, [gitbook]);
return (
<div className={className}>
<iframe
title={title ?? 'GitBook Assistant'}
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
/>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import type {
GetFrameURLOptions,
GitBookEmbeddableConfiguration,
GitBookFrameClient,
} from '../client';
import { useGitBook } from './GitBookProvider';
export type GitBookFrameProps = {
className?: string;
} & GetFrameURLOptions &
GitBookEmbeddableConfiguration;
/**
* Render a frame with the GitBook Assistant in it.
*/
export function GitBookFrame(props: GitBookFrameProps) {
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
const frameRef = React.useRef<HTMLIFrameElement>(null);
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
React.useEffect(() => {
if (frameRef.current) {
setGitbookFrame(gitbook.createFrame(frameRef.current));
}
}, [gitbook]);
React.useEffect(() => {
gitbookFrame?.configure({
buttons,
welcomeMessage,
suggestions,
tools,
});
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
return (
<iframe
title="GitBook"
ref={frameRef}
src={frameURL}
width="100%"
height="100%"
className={className}
/>
);
}
+1
View File
@@ -1 +1,2 @@
export * from './GitBookProvider'; export * from './GitBookProvider';
export * from './GitBookFrame';
+31 -11
View File
@@ -4,9 +4,8 @@ import {
type CreateGitBookOptions, type CreateGitBookOptions,
type GetFrameURLOptions, type GetFrameURLOptions,
type GitBookClient, type GitBookClient,
type GitBookEmbeddableConfiguration,
type GitBookFrameClient, type GitBookFrameClient,
type GitBookPlaceholderSettings,
type GitBookToolDefinition,
createGitBook, createGitBook,
} from '../client'; } from '../client';
@@ -29,12 +28,10 @@ type StandaloneCalls =
| ['toggle'] | ['toggle']
// Post a user message // Post a user message
| ['postUserMessage', string] | ['postUserMessage', string]
// Register a tool
| ['registerTool', GitBookToolDefinition]
// Clear the chat // Clear the chat
| ['clearChat'] | ['clearChat']
// Configure the placeholder // Configure the embed
| ['setPlaceholder', GitBookPlaceholderSettings] | ['configure', Partial<GitBookEmbeddableConfiguration>]
// Navigate to a page // Navigate to a page
| ['navigateToPage', string] | ['navigateToPage', string]
// Navigate to the assistant // Navigate to the assistant
@@ -65,6 +62,12 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined; let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined; let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined; let frameOptions: GetFrameURLOptions | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration = {
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
};
function getClient() { function getClient() {
if (!_client) { if (!_client) {
@@ -132,15 +135,30 @@ const GitBook = (...args: StandaloneCalls) => {
case 'postUserMessage': case 'postUserMessage':
getIframe().frame.postUserMessage(args[1]); getIframe().frame.postUserMessage(args[1]);
break; break;
case 'registerTool': case 'configure':
getIframe().frame.registerTool(args[1]); frameConfiguration = {
...frameConfiguration,
...args[1],
};
getIframe().frame.configure({
...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;
case 'setPlaceholder':
getIframe().frame.setPlaceholder(args[1]);
break;
case 'navigateToPage': case 'navigateToPage':
getIframe().frame.navigateToPage(args[1]); getIframe().frame.navigateToPage(args[1]);
break; break;
@@ -156,3 +174,5 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope // @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook; window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call)); precalls.forEach((call) => GitBook(...call));
GitBook('configure', {});
@@ -346,7 +346,8 @@ export function AIChatProvider(props: {
loading: false, loading: false,
error: false, error: false,
})); }));
} catch { } catch (error) {
console.error('Error streaming AI response', error);
globalState.setState((state) => ({ globalState.setState((state) => ({
...state, ...state,
loading: false, loading: false,
@@ -359,6 +360,7 @@ export function AIChatProvider(props: {
renderMessageOptions?.withLinkPreviews, renderMessageOptions?.withLinkPreviews,
renderMessageOptions?.withToolCalls, renderMessageOptions?.withToolCalls,
renderMessageOptions?.asEmbeddable, renderMessageOptions?.asEmbeddable,
language,
] ]
); );
@@ -11,10 +11,18 @@ import {
useAIChatController, useAIChatController,
useAIChatState, useAIChatState,
} from '../AI'; } from '../AI';
import { EmbeddableFrame } from '../Embeddable/EmbeddableFrame'; import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameSubtitle,
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useNow } from '../hooks'; import { useNow } from '../hooks';
import { Button } from '../primitives'; import { Button } from '../primitives';
import { DropdownMenu, DropdownMenuItem } from '../primitives'; import { AIChatControlButton } from './AIChatControlButton';
import { AIChatIcon } from './AIChatIcon'; import { AIChatIcon } from './AIChatIcon';
import { AIChatInput } from './AIChatInput'; import { AIChatInput } from './AIChatInput';
import { AIChatMessages } from './AIChatMessages'; import { AIChatMessages } from './AIChatMessages';
@@ -66,45 +74,17 @@ export function AIChat(props: { trademark: boolean }) {
data-testid="ai-chat" data-testid="ai-chat"
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96" className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
> >
<EmbeddableFrame <EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle" <EmbeddableFrameHeader>
icon={<AIChatDynamicIcon trademark={trademark} />} <AIChatDynamicIcon trademark={trademark} />
title={getAIChatName(language, trademark)} <EmbeddableFrameHeaderMain>
subtitle={ <EmbeddableFrameTitle>
chat.loading {getAIChatName(language, trademark)}
? chat.messages[chat.messages.length - 1].content </EmbeddableFrameTitle>
? tString(language, 'ai_chat_working') <AIChatSubtitle chat={chat} />
: tString(language, 'ai_chat_thinking') </EmbeddableFrameHeaderMain>
: '' <EmbeddableFrameButtons>
} <AIChatControlButton />
buttons={
<>
{chat.messages.length > 0 ? (
<DropdownMenu
button={
<Button
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'actions')}
variant="blank"
size="default"
/>
}
>
<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}
<Button <Button
onClick={() => chatController.close()} onClick={() => chatController.close()}
iconOnly iconOnly
@@ -113,10 +93,11 @@ export function AIChat(props: { trademark: boolean }) {
variant="blank" variant="blank"
size="default" size="default"
/> />
</> </EmbeddableFrameButtons>
} </EmbeddableFrameHeader>
> <EmbeddableFrameBody>
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} /> <AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
</EmbeddableFrameBody>
</EmbeddableFrame> </EmbeddableFrame>
</div> </div>
); );
@@ -139,7 +120,7 @@ export function AIChatDynamicIcon(props: {
chat.error chat.error
? 'error' ? 'error'
: chat.loading : chat.loading
? chat.messages[chat.messages.length - 1].content ? chat.messages[chat.messages.length - 1]?.content
? 'working' ? 'working'
: 'thinking' : 'thinking'
: chat.messages.length > 0 : chat.messages.length > 0
@@ -152,6 +133,24 @@ export function AIChatDynamicIcon(props: {
); );
} }
/**
* Subtitle of the AI chat window.
*/
export function AIChatSubtitle(props: {
chat: AIChatState;
}) {
const { chat } = props;
const language = useLanguage();
return (
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
{chat.messages[chat.messages.length - 1]?.content
? tString(language, 'ai_chat_working')
: tString(language, 'ai_chat_thinking')}
</EmbeddableFrameSubtitle>
);
}
/** /**
* Body of the AI chat window. * Body of the AI chat window.
*/ */
@@ -159,8 +158,10 @@ export function AIChatBody(props: {
chatController: AIChatController; chatController: AIChatController;
chat: AIChatState; chat: AIChatState;
trademark: boolean; trademark: boolean;
welcomeMessage?: string;
suggestions?: string[];
}) { }) {
const { chatController, chat, trademark } = props; const { chatController, chat, trademark, suggestions } = props;
const [input, setInput] = React.useState(''); const [input, setInput] = React.useState('');
@@ -246,7 +247,10 @@ export function AIChatBody(props: {
</p> </p>
</div> </div>
{!chat.error ? ( {!chat.error ? (
<AIChatSuggestedQuestions chatController={chatController} /> <AIChatSuggestedQuestions
chatController={chatController}
suggestions={suggestions}
/>
) : null} ) : null}
</div> </div>
) : ( ) : (
@@ -0,0 +1,40 @@
'use client';
import { useLanguage } from '@/intl/client';
import { t, tString } from '@/intl/translate';
import { Icon } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '../AI';
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
/**
* Button to control the chat (clear, etc.)
*/
export function AIChatControlButton() {
const language = useLanguage();
const chat = useAIChatState();
const chatController = useAIChatController();
return chat.messages.length > 0 ? (
<DropdownMenu
button={
<Button
onClick={() => {}}
iconOnly
icon="ellipsis"
label={tString(language, 'actions')}
variant="blank"
size="default"
/>
}
>
<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;
}
@@ -2,19 +2,23 @@ import { tString, useLanguage } from '@/intl/client';
import type { AIChatController } from '../AI'; import type { AIChatController } from '../AI';
import { Button } from '../primitives'; import { Button } from '../primitives';
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) { export default function AIChatSuggestedQuestions(props: {
const { chatController } = props; chatController: AIChatController;
suggestions?: string[];
}) {
const language = useLanguage(); const language = useLanguage();
const {
const DEFAULT_SUGGESTED_QUESTIONS = [ chatController,
tString(language, 'ai_chat_suggested_questions_about_this_page'), suggestions = [
tString(language, 'ai_chat_suggested_questions_read_next'), tString(language, 'ai_chat_suggested_questions_about_this_page'),
tString(language, 'ai_chat_suggested_questions_example'), tString(language, 'ai_chat_suggested_questions_read_next'),
]; tString(language, 'ai_chat_suggested_questions_example'),
],
} = props;
return ( return (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => ( {suggestions.map((question, index) => (
<Button <Button
key={question} key={question}
variant="secondary" variant="secondary"
@@ -2,3 +2,4 @@ export * from './AIChat';
export * from './AIChatButton'; export * from './AIChatButton';
export * from './AIChatIcon'; export * from './AIChatIcon';
export * from './AIResponseFeedback'; export * from './AIResponseFeedback';
export * from './AIChatControlButton';
@@ -1,7 +1,21 @@
'use client'; 'use client';
import { useAIChatController, useAIChatState } from '@/components/AI'; import { useAIChatController, useAIChatState } from '@/components/AI';
import { AIChatBody } from '@/components/AIChat'; import {
AIChatBody,
AIChatControlButton,
AIChatDynamicIcon,
AIChatSubtitle,
} from '@/components/AIChat';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
EmbeddableFrameTitle,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons, useEmbeddableConfiguration } from './EmbeddableIframeAPI';
/** /**
* Embeddable AI chat window in an iframe. * Embeddable AI chat window in an iframe.
@@ -12,6 +26,29 @@ export function EmbeddableAIChat(props: {
const { trademark } = props; const { trademark } = props;
const chat = useAIChatState(); const chat = useAIChatState();
const chatController = useAIChatController(); const chatController = useAIChatController();
const configuration = useEmbeddableConfiguration();
return <AIChatBody trademark={trademark} chatController={chatController} chat={chat} />; return (
<EmbeddableFrame>
<EmbeddableFrameHeader>
<AIChatDynamicIcon trademark={trademark} />
<EmbeddableFrameHeaderMain>
<EmbeddableFrameTitle>GitBook Assistant</EmbeddableFrameTitle>
<AIChatSubtitle chat={chat} />
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<AIChatControlButton />
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<AIChatBody
trademark={trademark}
chatController={chatController}
chat={chat}
suggestions={configuration.suggestions}
/>
</EmbeddableFrameBody>
</EmbeddableFrame>
);
} }
@@ -3,6 +3,15 @@ import { type PagePathParams, getSitePageData } from '@/components/SitePage';
import { PageBody } from '@/components/PageBody'; import { PageBody } from '@/components/PageBody';
import type { GitBookSiteContext } from '@/lib/context'; import type { GitBookSiteContext } from '@/lib/context';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Button } from '../primitives';
import {
EmbeddableFrame,
EmbeddableFrameBody,
EmbeddableFrameButtons,
EmbeddableFrameHeader,
EmbeddableFrameHeaderMain,
} from './EmbeddableFrame';
import { EmbeddableIframeButtons } from './EmbeddableIframeAPI';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -22,15 +31,33 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
}); });
return ( return (
<div className="flex-1 overflow-auto p-6"> <EmbeddableFrame>
<PageBody <EmbeddableFrameHeader>
context={context} <EmbeddableFrameHeaderMain>
page={page} <Button
ancestors={ancestors} href={context.linker.toPathInSite('~gitbook/embed/assistant')}
document={document} size="default"
withPageFeedback={withPageFeedback} variant="blank"
/> icon="arrow-left"
</div> label="Back"
/>
</EmbeddableFrameHeaderMain>
<EmbeddableFrameButtons>
<EmbeddableIframeButtons />
</EmbeddableFrameButtons>
</EmbeddableFrameHeader>
<EmbeddableFrameBody>
<div className="flex-1 overflow-auto p-6">
<PageBody
context={context}
page={page}
ancestors={ancestors}
document={document}
withPageFeedback={withPageFeedback}
/>
</div>
</EmbeddableFrameBody>
</EmbeddableFrame>
); );
} }
@@ -1,27 +1,17 @@
import { tcls } from '@/lib/tailwind'; import { tcls } from '@/lib/tailwind';
import React from 'react'; import React from 'react';
export type EmbeddableFrameHeaderProps = { export type EmbeddableFrameProps = React.ComponentProps<'div'> & {
icon: React.ReactNode; children: React.ReactNode;
title: string;
subtitle?: string;
buttons?: React.ReactNode;
}; };
export type EmbeddableFrameProps = EmbeddableFrameHeaderProps &
React.ComponentProps<'div'> & {
children: React.ReactNode;
};
/** /**
* Presentation component to display an embeddable frame. * Presentation component to display an embeddable frame.
* It is used for the AI chat window in the docs, but also when embedded in another website. * It is used for the AI chat window in the docs, but also when embedded in another website.
*/ */
export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameProps>( export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameProps>(
(props, ref) => { (props, ref) => {
const { icon, title, subtitle, buttons, children, ...divProps } = props; const { children, ...divProps } = props;
return ( return (
<div <div
@@ -32,22 +22,70 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
)} )}
ref={ref} ref={ref}
> >
<div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong"> {children}
{icon}
<div className="flex flex-col">
<div className="font-bold">{title}</div>
<div
className={`text-tint text-xs leading-none transition-all duration-500 ${
subtitle ? 'h-3 opacity-11' : 'h-0 opacity-0'
}`}
>
{subtitle}
</div>
</div>
<div className="ml-auto flex gap-2">{buttons}</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden">{children}</div>
</div> </div>
); );
} }
); );
export function EmbeddableFrameHeader(props: {
children: React.ReactNode;
}) {
const { children } = props;
return (
<div className="flex select-none items-center gap-2 border-tint-subtle border-b bg-tint-subtle px-4 py-2 text-tint-strong">
{children}
</div>
);
}
export function EmbeddableFrameHeaderMain(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="flex flex-1 flex-col">{children}</div>;
}
export function EmbeddableFrameBody(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="flex flex-1 flex-col overflow-hidden">{children}</div>;
}
export function EmbeddableFrameTitle(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="font-bold">{children}</div>;
}
export function EmbeddableFrameSubtitle(props: {
children: React.ReactNode;
className?: string;
}) {
const { children, className } = props;
return (
<div
className={tcls(
'text-tint text-xs leading-none transition-all duration-500',
className
)}
>
{children}
</div>
);
}
export function EmbeddableFrameButtons(props: {
children: React.ReactNode;
}) {
const { children } = props;
return <div className="ml-auto flex gap-2">{children}</div>;
}
@@ -1,15 +1,31 @@
'use client'; 'use client';
import type { ParentToFrameMessage } from '@gitbook/embed'; import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
import { createChannel } from 'bidc'; import { createChannel } from 'bidc';
import React from 'react'; import React from 'react';
import { useAIChatController } from '@/components/AI'; import { useAIChatController } from '@/components/AI';
import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
import { Button } from '../primitives';
const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() => ({
buttons: [],
welcomeMessage: '',
suggestions: [],
tools: [],
}));
/** /**
* Expose the API to communicate with the parent window. * Expose the API to communicate with the parent window.
*/ */
export function EmbeddableIframeAPI() { export function EmbeddableIframeAPI(props: {
baseURL: string;
}) {
const { baseURL } = props;
const router = useRouter();
const chatController = useAIChatController(); const chatController = useAIChatController();
React.useEffect(() => { React.useEffect(() => {
@@ -17,11 +33,14 @@ export function EmbeddableIframeAPI() {
return; return;
} }
console.log('[gitbook] create channel with parent window');
const channel = createChannel(); const channel = createChannel();
channel.receive((payload) => { channel.receive((payload) => {
const message = payload as ParentToFrameMessage; const message = payload as ParentToFrameMessage;
console.log('[gitbook] received message', message);
switch (message.type) { switch (message.type) {
case 'clearChat': { case 'clearChat': {
chatController.clear(); chatController.clear();
@@ -33,12 +52,64 @@ export function EmbeddableIframeAPI() {
}); });
break; break;
} }
// TODO: Handle other messages case 'configure': {
embeddableConfiguration.setState(message.settings);
integrationsAssistantTools.setState({
tools: message.settings.tools,
});
break;
}
case 'navigateToPage': {
router.push(`${baseURL}/page/${message.pagePath}`);
break;
}
case 'navigateToAssistant': {
router.push(`${baseURL}/assistant`);
break;
}
} }
}); });
return channel.cleanup(); return () => {
}, [chatController]); console.log('[gitbook] cleanup');
channel.cleanup();
};
}, [chatController, router, baseURL]);
return null; return null;
} }
/**
* Hook to get the configuration from the parent window.
*/
export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
// @ts-expect-error - This is a workaround to allow the function to be optional.
fn: (state: GitBookEmbeddableConfiguration) => T = (state) => state
) {
return useStore(embeddableConfiguration, fn);
}
/**
* Display the buttons defined by the parent window.
*/
export function EmbeddableIframeButtons() {
const buttons = useEmbeddableConfiguration((state) => state.buttons);
return (
<>
{buttons.map((button) => (
<Button
key={button.label}
size="default"
variant="blank"
icon={button.icon}
label={button.label}
iconOnly
onClick={() => {
button.onClick();
}}
/>
))}
</>
);
}
@@ -1,6 +1,4 @@
import { AIChatProvider, AIContextProvider } from '@/components/AI'; import { AIChatProvider, AIContextProvider } from '@/components/AI';
import { AIChatDynamicIcon } from '@/components/AIChat';
import { EmbeddableFrame } from '@/components/Embeddable';
import { CustomizationRootLayout } from '@/components/RootLayout'; import { CustomizationRootLayout } from '@/components/RootLayout';
import { import {
SiteLayoutClientContexts, SiteLayoutClientContexts,
@@ -29,26 +27,22 @@ export async function EmbeddableRootLayout({
externalLinksTarget={context.customization.externalLinks.target} externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId} contextId={context.contextId}
> >
<EmbeddableFrame <AIContextProvider
icon={<AIChatDynamicIcon trademark={context.customization.trademark.enabled} />} aiMode={CustomizationAIMode.Assistant}
title="Test" trademark={context.customization.trademark.enabled}
className="fixed inset-0"
> >
<AIContextProvider <AIChatProvider
aiMode={CustomizationAIMode.Assistant} renderMessageOptions={{
trademark={context.customization.trademark.enabled} withLinkPreviews: false,
asEmbeddable: true,
}}
> >
<AIChatProvider <div className="fixed inset-0 flex flex-col">{children}</div>
renderMessageOptions={{ <EmbeddableIframeAPI
withLinkPreviews: false, baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
asEmbeddable: true, />
}} </AIChatProvider>
> </AIContextProvider>
{children}
<EmbeddableIframeAPI />
</AIChatProvider>
</AIContextProvider>
</EmbeddableFrame>
</SiteLayoutClientContexts> </SiteLayoutClientContexts>
</CustomizationRootLayout> </CustomizationRootLayout>
); );