Skip to content

Commit 3ff88ba

Browse files
authored
Adds the prompt block (#4329)
1 parent 51bd768 commit 3ff88ba

47 files changed

Lines changed: 354 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/lovely-cameras-sip.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Add a Prompt block

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"catalog": {
4444
"@tsconfig/strictest": "^2.0.6",
4545
"@tsconfig/node20": "^20.1.6",
46-
"@gitbook/api": "0.184.0",
46+
"@gitbook/api": "0.185.0",
4747
"@scalar/api-client-react": "^1.3.46",
4848
"@types/react": "^19.0.0",
4949
"@types/react-dom": "^19.0.0",

packages/gitbook/src/components/DocumentView/Block.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ListItem } from './ListItem';
2828
import { BlockMath } from './Math';
2929
import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI';
3030
import { Paragraph } from './Paragraph';
31+
import { Prompt } from './Prompt';
3132
import { Quote } from './Quote';
3233
import { ReusableContent } from './ReusableContent';
3334
import { Stepper } from './Stepper';
@@ -111,6 +112,8 @@ export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
111112
return <Updates {...props} block={block} />;
112113
case 'update':
113114
return <Update {...props} block={block} />;
115+
case 'prompt':
116+
return <Prompt {...props} block={block} />;
114117
case 'if':
115118
// If block should be processed by the API.
116119
return null;
@@ -151,6 +154,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue }
151154
case 'hint':
152155
case 'tabs':
153156
case 'stepper-step':
157+
case 'prompt':
154158
case 'if':
155159
return <SkeletonParagraph id={id} className={style} />;
156160
case 'expandable':
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { tcls } from '@/lib/tailwind';
2+
import {
3+
CustomizationPageActionType,
4+
type DocumentBlockPrompt,
5+
type SiteCustomizationSettings,
6+
} from '@gitbook/api';
7+
import { validateIconName } from '@gitbook/icons/icons';
8+
import type { BlockProps } from '../Block';
9+
import { getPlainCodeBlock } from '../CodeBlock/highlight';
10+
import { PromptClient } from './PromptClient';
11+
12+
export function Prompt(props: BlockProps<DocumentBlockPrompt>) {
13+
const { block } = props;
14+
const contentIcon =
15+
block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null;
16+
17+
return (
18+
<div
19+
className={tcls(
20+
'relative flex w-full flex-col overflow-hidden',
21+
'border border-tint-subtle bg-tint-subtle theme-bold-tint:bg-tint-base theme-muted:bg-tint-base text-tint-strong contrast-more:border-tint contrast-more:bg-tint-base',
22+
'circular-corners:rounded-2xl rounded-corners:rounded-xl straight-corners:rounded-xs',
23+
'depth-subtle:shadow-xs'
24+
)}
25+
>
26+
<PromptClient
27+
contentIcon={contentIcon}
28+
description={block.data.description}
29+
prompt={getPromptText(block)}
30+
openInAIProviders={getOpenInAIProviders(props)}
31+
/>
32+
</div>
33+
);
34+
}
35+
36+
function getOpenInAIProviders(props: BlockProps<DocumentBlockPrompt>): boolean {
37+
const { block, context } = props;
38+
const { openInAIProviders } = block.data;
39+
40+
if (openInAIProviders !== undefined) {
41+
return openInAIProviders;
42+
}
43+
44+
const contentContext = context.contentContext;
45+
if (contentContext && 'customization' in contentContext) {
46+
const { pageActions } = contentContext.customization;
47+
return isExternalAIPageActionEnabled(pageActions);
48+
}
49+
50+
return false;
51+
}
52+
53+
function isExternalAIPageActionEnabled(
54+
pageActions: SiteCustomizationSettings['pageActions']
55+
): boolean {
56+
return pageActions.items
57+
? pageActions.items.includes(CustomizationPageActionType.ExternalAi)
58+
: pageActions.externalAI;
59+
}
60+
61+
function getPromptText(block: DocumentBlockPrompt): string {
62+
return (block.nodes ?? []).map((node) => getPlainCodeBlock(node)).join('\n');
63+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
'use client';
2+
3+
import {
4+
Button,
5+
ButtonGroup,
6+
DropdownMenu,
7+
DropdownMenuItem,
8+
ToggleChevron,
9+
} from '@/components/primitives';
10+
import { tString, useLanguage } from '@/intl/client';
11+
import { tcls } from '@/lib/tailwind';
12+
import { Icon, type IconName } from '@gitbook/icons';
13+
import React from 'react';
14+
15+
const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const;
16+
type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number];
17+
18+
export function PromptClient(props: {
19+
contentIcon: IconName | null;
20+
description: string;
21+
prompt: string;
22+
openInAIProviders: boolean;
23+
}) {
24+
const { contentIcon, description, prompt, openInAIProviders } = props;
25+
const language = useLanguage();
26+
const promptId = React.useId();
27+
const [open, setOpen] = React.useState(false);
28+
const [headerHasFocus, setHeaderHasFocus] = React.useState(false);
29+
return (
30+
<>
31+
<div className="group/prompt-header relative flex min-h-9 flex-row items-center justify-between gap-4 px-3 py-2">
32+
<button
33+
type="button"
34+
aria-controls={promptId}
35+
aria-expanded={open}
36+
aria-label={tString(language, 'view')}
37+
className={tcls(
38+
'absolute inset-0 z-10 cursor-pointer outline-hidden',
39+
'focus-visible:ring-2 focus-visible:ring-primary-hover'
40+
)}
41+
disabled={!prompt}
42+
onBlur={() => setHeaderHasFocus(false)}
43+
onClick={() => setOpen((prev) => !prev)}
44+
onFocus={() => setHeaderHasFocus(true)}
45+
/>
46+
<div className="pointer-events-none relative z-0 flex min-w-0 flex-row items-center gap-2 text-tint-strong">
47+
<PromptDisclosureIcon
48+
contentIcon={contentIcon}
49+
headerHasFocus={headerHasFocus}
50+
open={open}
51+
/>
52+
<span className="min-w-0 truncate">{description}</span>
53+
</div>
54+
<PromptActions prompt={prompt} openInAIProviders={openInAIProviders} />
55+
</div>
56+
{open ? (
57+
<div id={promptId} className="border-tint-subtle border-t bg-tint-base">
58+
<pre className="overflow-auto p-4 text-sm text-tint-strong">
59+
<code className="language-markdown whitespace-pre-wrap font-mono">
60+
{prompt}
61+
</code>
62+
</pre>
63+
</div>
64+
) : null}
65+
</>
66+
);
67+
}
68+
69+
function PromptDisclosureIcon(props: {
70+
contentIcon: IconName | null;
71+
headerHasFocus: boolean;
72+
open: boolean;
73+
}) {
74+
const { contentIcon, headerHasFocus, open } = props;
75+
return (
76+
<span className="relative flex size-4 shrink-0 items-center justify-center">
77+
{contentIcon ? (
78+
<>
79+
<span
80+
className={tcls(
81+
'flex items-center transition-opacity duration-150 group-hover/prompt-header:opacity-0',
82+
headerHasFocus && 'opacity-0'
83+
)}
84+
>
85+
<Icon icon={contentIcon} className="size-4 shrink-0" />
86+
</span>
87+
<span
88+
className={tcls(
89+
'absolute inset-0 flex items-center justify-center text-tint-subtle opacity-0 transition-opacity duration-150 group-hover/prompt-header:opacity-100',
90+
headerHasFocus && 'opacity-100'
91+
)}
92+
>
93+
<ToggleChevron open={open} orientation="right-to-down" className="size-3" />
94+
</span>
95+
</>
96+
) : (
97+
<ToggleChevron
98+
open={open}
99+
orientation="right-to-down"
100+
className="size-3 text-tint-subtle"
101+
/>
102+
)}
103+
</span>
104+
);
105+
}
106+
107+
function PromptActions(props: { prompt: string; openInAIProviders: boolean }) {
108+
const { prompt, openInAIProviders } = props;
109+
110+
return (
111+
<ButtonGroup className="relative z-20 shrink-0 overflow-visible">
112+
<CopyPromptButton prompt={prompt} />
113+
{openInAIProviders ? <OpenPromptDropdown prompt={prompt} /> : null}
114+
</ButtonGroup>
115+
);
116+
}
117+
118+
// time in milliseconds to show the "Copied" message after copying a prompt
119+
const COPIED_MESSAGE_DURATION = 1000;
120+
121+
function CopyPromptButton(props: { prompt: string }) {
122+
const { prompt } = props;
123+
const language = useLanguage();
124+
const [copied, setCopied] = React.useState(false);
125+
126+
React.useEffect(() => {
127+
if (!copied) {
128+
return;
129+
}
130+
131+
const timeout = setTimeout(() => {
132+
setCopied(false);
133+
}, COPIED_MESSAGE_DURATION);
134+
135+
return () => {
136+
clearTimeout(timeout);
137+
};
138+
}, [copied]);
139+
140+
return (
141+
<Button
142+
variant="secondary"
143+
size="xsmall"
144+
icon={copied ? 'check' : 'copy'}
145+
label={copied ? tString(language, 'code_copied') : tString(language, 'prompt_copy')}
146+
className="bg-tint-base"
147+
disabled={!prompt}
148+
onClick={() => {
149+
navigator.clipboard.writeText(prompt);
150+
setCopied(true);
151+
}}
152+
/>
153+
);
154+
}
155+
156+
function OpenPromptDropdown(props: { prompt: string }) {
157+
const { prompt } = props;
158+
const language = useLanguage();
159+
160+
return (
161+
<DropdownMenu
162+
align="end"
163+
className="!min-w-48 max-w-max"
164+
button={
165+
<Button
166+
icon={<ToggleChevron className="size-text-sm" />}
167+
label={tString(language, 'open')}
168+
iconOnly
169+
size="xsmall"
170+
variant="secondary"
171+
className="bg-tint-base"
172+
disabled={!prompt}
173+
/>
174+
}
175+
>
176+
{OPEN_IN_AI_PROVIDERS.map((provider) => {
177+
const definition = getPromptOpenActionDefinition(provider, prompt);
178+
179+
return (
180+
<DropdownMenuItem
181+
key={provider}
182+
href={definition.href}
183+
target="_blank"
184+
leadingIcon={definition.icon}
185+
>
186+
{tString(language, 'open_in', definition.label)}
187+
</DropdownMenuItem>
188+
);
189+
})}
190+
</DropdownMenu>
191+
);
192+
}
193+
194+
function getPromptOpenActionDefinition(
195+
action: AIProviders,
196+
prompt: string
197+
): { href: string; icon: IconName; label: string } {
198+
const encodedPrompt = encodeURIComponent(prompt);
199+
200+
switch (action) {
201+
case 'cursor':
202+
return {
203+
href: `${CURSOR_PROMPT_URL}?text=${encodedPrompt}`,
204+
icon: 'cursor',
205+
label: 'Cursor',
206+
};
207+
case 'claude':
208+
return {
209+
href: `${CLAUDE_PROMPT_URL}?q=${encodedPrompt}`,
210+
icon: 'claude',
211+
label: 'Claude',
212+
};
213+
case 'chatgpt':
214+
return {
215+
href: `${CHATGPT_PROMPT_URL}?q=${encodedPrompt}`,
216+
icon: 'chatgpt',
217+
label: 'ChatGPT',
218+
};
219+
}
220+
}
221+
222+
const CLAUDE_PROMPT_URL = 'https://claude.ai/new';
223+
const CHATGPT_PROMPT_URL = 'https://chat.openai.com/';
224+
const CURSOR_PROMPT_URL = 'https://cursor.com/link/prompt';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './Prompt';
2+
export type * from './types';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { DocumentBlockCode } from '@gitbook/api';
2+
3+
export type PromptBlock = {
4+
object: 'block';
5+
type: 'prompt';
6+
key?: string;
7+
data: {
8+
icon?: string;
9+
description?: string;
10+
openInAIProviders?: boolean;
11+
};
12+
nodes?: DocumentBlockCode[];
13+
isVoid?: false;
14+
};

packages/gitbook/src/intl/translations/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const ar: TranslationLanguage = {
5656
annotation_button_label: 'فتح التعليق التوضيحي',
5757
code_copied: 'تم النسخ!',
5858
code_copy: 'نسخ',
59+
prompt_copy: 'نسخ الموجّه',
5960
code_block_collapsed: 'عرض كل الأسطر ${1}',
6061
code_block_expanded: 'عرض أقل',
6162
table_of_contents_button_label: 'فتح جدول المحتويات',

packages/gitbook/src/intl/translations/bg.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const bg: TranslationLanguage = {
5656
annotation_button_label: 'Отваряне на бележка',
5757
code_copied: 'Копирано!',
5858
code_copy: 'Копиране',
59+
prompt_copy: 'Копиране на подканата',
5960
code_block_collapsed: 'Показване на всички ${1} реда',
6061
code_block_expanded: 'Показване на по-малко',
6162
table_of_contents_button_label: 'Отваряне на съдържанието',

0 commit comments

Comments
 (0)