mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
Embeddable GitBook API (#3593)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/embed": minor
|
||||
---
|
||||
|
||||
Improve API to control the GitBook embed
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support customization of buttons and tools through iframe API
|
||||
@@ -18,6 +18,7 @@
|
||||
"**/.vercel/**/*",
|
||||
"**/.cache/**/*",
|
||||
"**/.wrangler/**/*",
|
||||
"packages/embed/standalone/**/*",
|
||||
"packages/openapi-parser/src/fixtures/**/*",
|
||||
"packages/emoji-codepoints/index.ts",
|
||||
"packages/icons/src/data/*.json",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createChannel } from 'bidc';
|
||||
import type {
|
||||
FrameToParentMessage,
|
||||
GitBookPlaceholderSettings,
|
||||
GitBookToolDefinition,
|
||||
GitBookEmbeddableConfiguration,
|
||||
ParentToFrameMessage,
|
||||
} from './protocol';
|
||||
|
||||
@@ -22,11 +21,6 @@ export type GitBookFrameClient = {
|
||||
*/
|
||||
postUserMessage: (message: string) => void;
|
||||
|
||||
/**
|
||||
* Register a custom tool.
|
||||
*/
|
||||
registerTool: (tool: GitBookToolDefinition) => void;
|
||||
|
||||
/**
|
||||
* Clear the chat.
|
||||
*/
|
||||
@@ -35,7 +29,7 @@ export type GitBookFrameClient = {
|
||||
/**
|
||||
* Set the placeholder settings.
|
||||
*/
|
||||
setPlaceholder: (placeholder: GitBookPlaceholderSettings) => void;
|
||||
configure: (settings: Partial<GitBookEmbeddableConfiguration>) => void;
|
||||
|
||||
/**
|
||||
* Register an event listener.
|
||||
@@ -53,6 +47,7 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
const channel = createChannel(iframe.contentWindow);
|
||||
|
||||
channel.receive((message: FrameToParentMessage) => {
|
||||
console.log('[gitbook:embed] received message', message);
|
||||
if (message.type === 'close') {
|
||||
const listeners = events.get('close') || [];
|
||||
if (listeners) {
|
||||
@@ -62,11 +57,19 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
});
|
||||
|
||||
const sendToFrame = (message: ParentToFrameMessage) => {
|
||||
console.log('[gitbook:embed] send message', message);
|
||||
channel.send(message);
|
||||
};
|
||||
|
||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
const configuration: GitBookEmbeddableConfiguration = {
|
||||
buttons: [],
|
||||
welcomeMessage: '',
|
||||
suggestions: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
return {
|
||||
navigateToPage: (pagePath) => {
|
||||
sendToFrame({ type: 'navigateToPage', pagePath });
|
||||
@@ -75,9 +78,11 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
||||
sendToFrame({ type: 'navigateToAssistant' });
|
||||
},
|
||||
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' }),
|
||||
setPlaceholder: (settings) => sendToFrame({ type: 'setPlaceholder', settings }),
|
||||
on: (event, listener) => {
|
||||
const listeners = events.get(event) || [];
|
||||
listeners.push(listener);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
/** Tools to be provided to the assistant. */
|
||||
tools: GitBookToolDefinition[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,16 +67,12 @@ export type ParentToFrameMessage =
|
||||
type: 'postUserMessage';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'registerTool';
|
||||
tool: GitBookToolDefinition;
|
||||
}
|
||||
| {
|
||||
type: 'clearChat';
|
||||
}
|
||||
| {
|
||||
type: 'setPlaceholder';
|
||||
settings: GitBookPlaceholderSettings;
|
||||
type: 'configure';
|
||||
settings: GitBookEmbeddableConfiguration;
|
||||
}
|
||||
| {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
export * from './GitBookProvider';
|
||||
export * from './GitBookFrame';
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
type CreateGitBookOptions,
|
||||
type GetFrameURLOptions,
|
||||
type GitBookClient,
|
||||
type GitBookEmbeddableConfiguration,
|
||||
type GitBookFrameClient,
|
||||
type GitBookPlaceholderSettings,
|
||||
type GitBookToolDefinition,
|
||||
createGitBook,
|
||||
} from '../client';
|
||||
|
||||
@@ -29,12 +28,10 @@ type StandaloneCalls =
|
||||
| ['toggle']
|
||||
// Post a user message
|
||||
| ['postUserMessage', string]
|
||||
// Register a tool
|
||||
| ['registerTool', GitBookToolDefinition]
|
||||
// Clear the chat
|
||||
| ['clearChat']
|
||||
// Configure the placeholder
|
||||
| ['setPlaceholder', GitBookPlaceholderSettings]
|
||||
// Configure the embed
|
||||
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
||||
// Navigate to a page
|
||||
| ['navigateToPage', string]
|
||||
// Navigate to the assistant
|
||||
@@ -65,6 +62,12 @@ 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() {
|
||||
if (!_client) {
|
||||
@@ -132,15 +135,30 @@ const GitBook = (...args: StandaloneCalls) => {
|
||||
case 'postUserMessage':
|
||||
getIframe().frame.postUserMessage(args[1]);
|
||||
break;
|
||||
case 'registerTool':
|
||||
getIframe().frame.registerTool(args[1]);
|
||||
case 'configure':
|
||||
frameConfiguration = {
|
||||
...frameConfiguration,
|
||||
...args[1],
|
||||
};
|
||||
getIframe().frame.configure({
|
||||
...frameConfiguration,
|
||||
buttons: [
|
||||
...frameConfiguration.buttons,
|
||||
|
||||
// Always include a close button
|
||||
{
|
||||
icon: 'close',
|
||||
label: 'Close',
|
||||
onClick: () => {
|
||||
GitBook('close');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
case 'clearChat':
|
||||
getIframe().frame.clearChat();
|
||||
break;
|
||||
case 'setPlaceholder':
|
||||
getIframe().frame.setPlaceholder(args[1]);
|
||||
break;
|
||||
case 'navigateToPage':
|
||||
getIframe().frame.navigateToPage(args[1]);
|
||||
break;
|
||||
@@ -156,3 +174,5 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
|
||||
// @ts-expect-error - GitBook is not defined in the global scope
|
||||
window.GitBook = GitBook;
|
||||
precalls.forEach((call) => GitBook(...call));
|
||||
|
||||
GitBook('configure', {});
|
||||
|
||||
@@ -346,7 +346,8 @@ export function AIChatProvider(props: {
|
||||
loading: false,
|
||||
error: false,
|
||||
}));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Error streaming AI response', error);
|
||||
globalState.setState((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
@@ -359,6 +360,7 @@ export function AIChatProvider(props: {
|
||||
renderMessageOptions?.withLinkPreviews,
|
||||
renderMessageOptions?.withToolCalls,
|
||||
renderMessageOptions?.asEmbeddable,
|
||||
language,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -11,10 +11,18 @@ import {
|
||||
useAIChatController,
|
||||
useAIChatState,
|
||||
} from '../AI';
|
||||
import { EmbeddableFrame } from '../Embeddable/EmbeddableFrame';
|
||||
import {
|
||||
EmbeddableFrame,
|
||||
EmbeddableFrameBody,
|
||||
EmbeddableFrameButtons,
|
||||
EmbeddableFrameHeader,
|
||||
EmbeddableFrameHeaderMain,
|
||||
EmbeddableFrameSubtitle,
|
||||
EmbeddableFrameTitle,
|
||||
} from '../Embeddable/EmbeddableFrame';
|
||||
import { useNow } from '../hooks';
|
||||
import { Button } from '../primitives';
|
||||
import { DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
import { AIChatControlButton } from './AIChatControlButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { AIChatMessages } from './AIChatMessages';
|
||||
@@ -66,45 +74,17 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
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"
|
||||
>
|
||||
<EmbeddableFrame
|
||||
className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle"
|
||||
icon={<AIChatDynamicIcon trademark={trademark} />}
|
||||
title={getAIChatName(language, trademark)}
|
||||
subtitle={
|
||||
chat.loading
|
||||
? chat.messages[chat.messages.length - 1].content
|
||||
? tString(language, 'ai_chat_working')
|
||||
: tString(language, 'ai_chat_thinking')
|
||||
: ''
|
||||
}
|
||||
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}
|
||||
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameTitle>
|
||||
{getAIChatName(language, trademark)}
|
||||
</EmbeddableFrameTitle>
|
||||
<AIChatSubtitle chat={chat} />
|
||||
</EmbeddableFrameHeaderMain>
|
||||
<EmbeddableFrameButtons>
|
||||
<AIChatControlButton />
|
||||
<Button
|
||||
onClick={() => chatController.close()}
|
||||
iconOnly
|
||||
@@ -113,10 +93,11 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
variant="blank"
|
||||
size="default"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
|
||||
</EmbeddableFrameButtons>
|
||||
</EmbeddableFrameHeader>
|
||||
<EmbeddableFrameBody>
|
||||
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -139,7 +120,7 @@ export function AIChatDynamicIcon(props: {
|
||||
chat.error
|
||||
? 'error'
|
||||
: chat.loading
|
||||
? chat.messages[chat.messages.length - 1].content
|
||||
? chat.messages[chat.messages.length - 1]?.content
|
||||
? 'working'
|
||||
: 'thinking'
|
||||
: 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.
|
||||
*/
|
||||
@@ -159,8 +158,10 @@ export function AIChatBody(props: {
|
||||
chatController: AIChatController;
|
||||
chat: AIChatState;
|
||||
trademark: boolean;
|
||||
welcomeMessage?: string;
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const { chatController, chat, trademark } = props;
|
||||
const { chatController, chat, trademark, suggestions } = props;
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
@@ -246,7 +247,10 @@ export function AIChatBody(props: {
|
||||
</p>
|
||||
</div>
|
||||
{!chat.error ? (
|
||||
<AIChatSuggestedQuestions chatController={chatController} />
|
||||
<AIChatSuggestedQuestions
|
||||
chatController={chatController}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
) : null}
|
||||
</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 { Button } from '../primitives';
|
||||
|
||||
export default function AIChatSuggestedQuestions(props: { chatController: AIChatController }) {
|
||||
const { chatController } = props;
|
||||
export default function AIChatSuggestedQuestions(props: {
|
||||
chatController: AIChatController;
|
||||
suggestions?: string[];
|
||||
}) {
|
||||
const language = useLanguage();
|
||||
|
||||
const DEFAULT_SUGGESTED_QUESTIONS = [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
];
|
||||
const {
|
||||
chatController,
|
||||
suggestions = [
|
||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||
tString(language, 'ai_chat_suggested_questions_example'),
|
||||
],
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{DEFAULT_SUGGESTED_QUESTIONS.map((question, index) => (
|
||||
{suggestions.map((question, index) => (
|
||||
<Button
|
||||
key={question}
|
||||
variant="secondary"
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './AIChat';
|
||||
export * from './AIChatButton';
|
||||
export * from './AIChatIcon';
|
||||
export * from './AIResponseFeedback';
|
||||
export * from './AIChatControlButton';
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
'use client';
|
||||
|
||||
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.
|
||||
@@ -12,6 +26,29 @@ export function EmbeddableAIChat(props: {
|
||||
const { trademark } = props;
|
||||
const chat = useAIChatState();
|
||||
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 type { GitBookSiteContext } from '@/lib/context';
|
||||
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';
|
||||
|
||||
@@ -22,15 +31,33 @@ export async function EmbeddableDocsPage(props: EmbeddableDocsPageProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<PageBody
|
||||
context={context}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
withPageFeedback={withPageFeedback}
|
||||
/>
|
||||
</div>
|
||||
<EmbeddableFrame>
|
||||
<EmbeddableFrameHeader>
|
||||
<EmbeddableFrameHeaderMain>
|
||||
<Button
|
||||
href={context.linker.toPathInSite('~gitbook/embed/assistant')}
|
||||
size="default"
|
||||
variant="blank"
|
||||
icon="arrow-left"
|
||||
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 React from 'react';
|
||||
|
||||
export type EmbeddableFrameHeaderProps = {
|
||||
icon: React.ReactNode;
|
||||
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
|
||||
buttons?: React.ReactNode;
|
||||
export type EmbeddableFrameProps = React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export type EmbeddableFrameProps = EmbeddableFrameHeaderProps &
|
||||
React.ComponentProps<'div'> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameProps>(
|
||||
(props, ref) => {
|
||||
const { icon, title, subtitle, buttons, children, ...divProps } = props;
|
||||
const { children, ...divProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,22 +22,70 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
|
||||
)}
|
||||
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">
|
||||
{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>
|
||||
{children}
|
||||
</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';
|
||||
|
||||
import type { ParentToFrameMessage } from '@gitbook/embed';
|
||||
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
|
||||
import { createChannel } from 'bidc';
|
||||
import React from 'react';
|
||||
|
||||
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.
|
||||
*/
|
||||
export function EmbeddableIframeAPI() {
|
||||
export function EmbeddableIframeAPI(props: {
|
||||
baseURL: string;
|
||||
}) {
|
||||
const { baseURL } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -17,11 +33,14 @@ export function EmbeddableIframeAPI() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[gitbook] create channel with parent window');
|
||||
const channel = createChannel();
|
||||
|
||||
channel.receive((payload) => {
|
||||
const message = payload as ParentToFrameMessage;
|
||||
|
||||
console.log('[gitbook] received message', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'clearChat': {
|
||||
chatController.clear();
|
||||
@@ -33,12 +52,64 @@ export function EmbeddableIframeAPI() {
|
||||
});
|
||||
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();
|
||||
}, [chatController]);
|
||||
return () => {
|
||||
console.log('[gitbook] cleanup');
|
||||
channel.cleanup();
|
||||
};
|
||||
}, [chatController, router, baseURL]);
|
||||
|
||||
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 { AIChatDynamicIcon } from '@/components/AIChat';
|
||||
import { EmbeddableFrame } from '@/components/Embeddable';
|
||||
import { CustomizationRootLayout } from '@/components/RootLayout';
|
||||
import {
|
||||
SiteLayoutClientContexts,
|
||||
@@ -29,26 +27,22 @@ export async function EmbeddableRootLayout({
|
||||
externalLinksTarget={context.customization.externalLinks.target}
|
||||
contextId={context.contextId}
|
||||
>
|
||||
<EmbeddableFrame
|
||||
icon={<AIChatDynamicIcon trademark={context.customization.trademark.enabled} />}
|
||||
title="Test"
|
||||
className="fixed inset-0"
|
||||
<AIContextProvider
|
||||
aiMode={CustomizationAIMode.Assistant}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
>
|
||||
<AIContextProvider
|
||||
aiMode={CustomizationAIMode.Assistant}
|
||||
trademark={context.customization.trademark.enabled}
|
||||
<AIChatProvider
|
||||
renderMessageOptions={{
|
||||
withLinkPreviews: false,
|
||||
asEmbeddable: true,
|
||||
}}
|
||||
>
|
||||
<AIChatProvider
|
||||
renderMessageOptions={{
|
||||
withLinkPreviews: false,
|
||||
asEmbeddable: true,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<EmbeddableIframeAPI />
|
||||
</AIChatProvider>
|
||||
</AIContextProvider>
|
||||
</EmbeddableFrame>
|
||||
<div className="fixed inset-0 flex flex-col">{children}</div>
|
||||
<EmbeddableIframeAPI
|
||||
baseURL={context.linker.toPathInSpace('~gitbook/embed/')}
|
||||
/>
|
||||
</AIChatProvider>
|
||||
</AIContextProvider>
|
||||
</SiteLayoutClientContexts>
|
||||
</CustomizationRootLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user