diff --git a/.changeset/lovely-cameras-sip.md b/.changeset/lovely-cameras-sip.md new file mode 100644 index 000000000..5c521ae71 --- /dev/null +++ b/.changeset/lovely-cameras-sip.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Add a Prompt block diff --git a/bun.lock b/bun.lock index c8a3208bf..a4b01f730 100644 --- a/bun.lock +++ b/bun.lock @@ -360,7 +360,7 @@ "react-dom": "catalog:", }, "catalog": { - "@gitbook/api": "0.184.0", + "@gitbook/api": "0.185.0", "@scalar/api-client-react": "^1.3.46", "@tsconfig/node20": "^20.1.6", "@tsconfig/strictest": "^2.0.6", @@ -756,7 +756,7 @@ "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="], - "@gitbook/api": ["@gitbook/api@0.184.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yPoQqLLik6IihFcwVVbuEAIyDA7F2q80HprNRuj10m+O2XULlm4+bW5R71r5/GCjDKSf5QfmRXmYh4OopljPKw=="], + "@gitbook/api": ["@gitbook/api@0.185.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-RrFZSHI7W79ri5jHd/gp01ZbuxfOPOFEqnom956wNUfUf/CIQDZcCNzmvLIWzuYesI7Snq3NLi+U5XzOsJhC4g=="], "@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"], diff --git a/package.json b/package.json index f2a7592a7..b11176ae4 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "catalog": { "@tsconfig/strictest": "^2.0.6", "@tsconfig/node20": "^20.1.6", - "@gitbook/api": "0.184.0", + "@gitbook/api": "0.185.0", "@scalar/api-client-react": "^1.3.46", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/gitbook/src/components/DocumentView/Block.tsx b/packages/gitbook/src/components/DocumentView/Block.tsx index 8681232f2..b77d8bdc0 100644 --- a/packages/gitbook/src/components/DocumentView/Block.tsx +++ b/packages/gitbook/src/components/DocumentView/Block.tsx @@ -28,6 +28,7 @@ import { ListItem } from './ListItem'; import { BlockMath } from './Math'; import { OpenAPIOperation, OpenAPISchemas, OpenAPIWebhook } from './OpenAPI'; import { Paragraph } from './Paragraph'; +import { Prompt } from './Prompt'; import { Quote } from './Quote'; import { ReusableContent } from './ReusableContent'; import { Stepper } from './Stepper'; @@ -111,6 +112,8 @@ export function Block(props: BlockProps) { return ; case 'update': return ; + case 'prompt': + return ; case 'if': // If block should be processed by the API. return null; @@ -151,6 +154,7 @@ export function BlockSkeleton(props: { block: DocumentBlock; style: ClassValue } case 'hint': case 'tabs': case 'stepper-step': + case 'prompt': case 'if': return ; case 'expandable': diff --git a/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx b/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx new file mode 100644 index 000000000..dc214406a --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Prompt/Prompt.tsx @@ -0,0 +1,63 @@ +import { tcls } from '@/lib/tailwind'; +import { + CustomizationPageActionType, + type DocumentBlockPrompt, + type SiteCustomizationSettings, +} from '@gitbook/api'; +import { validateIconName } from '@gitbook/icons/icons'; +import type { BlockProps } from '../Block'; +import { getPlainCodeBlock } from '../CodeBlock/highlight'; +import { PromptClient } from './PromptClient'; + +export function Prompt(props: BlockProps) { + const { block } = props; + const contentIcon = + block.data.icon && validateIconName(block.data.icon) ? block.data.icon : null; + + return ( +
+ +
+ ); +} + +function getOpenInAIProviders(props: BlockProps): boolean { + const { block, context } = props; + const { openInAIProviders } = block.data; + + if (openInAIProviders !== undefined) { + return openInAIProviders; + } + + const contentContext = context.contentContext; + if (contentContext && 'customization' in contentContext) { + const { pageActions } = contentContext.customization; + return isExternalAIPageActionEnabled(pageActions); + } + + return false; +} + +function isExternalAIPageActionEnabled( + pageActions: SiteCustomizationSettings['pageActions'] +): boolean { + return pageActions.items + ? pageActions.items.includes(CustomizationPageActionType.ExternalAi) + : pageActions.externalAI; +} + +function getPromptText(block: DocumentBlockPrompt): string { + return (block.nodes ?? []).map((node) => getPlainCodeBlock(node)).join('\n'); +} diff --git a/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx b/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx new file mode 100644 index 000000000..9b98d006f --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Prompt/PromptClient.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { + Button, + ButtonGroup, + DropdownMenu, + DropdownMenuItem, + ToggleChevron, +} from '@/components/primitives'; +import { tString, useLanguage } from '@/intl/client'; +import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import React from 'react'; + +const OPEN_IN_AI_PROVIDERS = ['claude', 'chatgpt', 'cursor'] as const; +type AIProviders = (typeof OPEN_IN_AI_PROVIDERS)[number]; + +export function PromptClient(props: { + contentIcon: IconName | null; + description: string; + prompt: string; + openInAIProviders: boolean; +}) { + const { contentIcon, description, prompt, openInAIProviders } = props; + const language = useLanguage(); + const promptId = React.useId(); + const [open, setOpen] = React.useState(false); + const [headerHasFocus, setHeaderHasFocus] = React.useState(false); + return ( + <> +
+
+ {open ? ( +
+
+                        
+                            {prompt}
+                        
+                    
+
+ ) : null} + + ); +} + +function PromptDisclosureIcon(props: { + contentIcon: IconName | null; + headerHasFocus: boolean; + open: boolean; +}) { + const { contentIcon, headerHasFocus, open } = props; + return ( + + {contentIcon ? ( + <> + + + + + + + + ) : ( + + )} + + ); +} + +function PromptActions(props: { prompt: string; openInAIProviders: boolean }) { + const { prompt, openInAIProviders } = props; + + return ( + + + {openInAIProviders ? : null} + + ); +} + +// time in milliseconds to show the "Copied" message after copying a prompt +const COPIED_MESSAGE_DURATION = 1000; + +function CopyPromptButton(props: { prompt: string }) { + const { prompt } = props; + const language = useLanguage(); + const [copied, setCopied] = React.useState(false); + + React.useEffect(() => { + if (!copied) { + return; + } + + const timeout = setTimeout(() => { + setCopied(false); + }, COPIED_MESSAGE_DURATION); + + return () => { + clearTimeout(timeout); + }; + }, [copied]); + + return ( +