-
Notifications
You must be signed in to change notification settings - Fork 4k
Implement AI actions dropdown #3431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
ea1c86d
Add AIActionsDropdown
nolannbiron 037b73f
Update
nolannbiron 84907fe
Fixes
nolannbiron eb447f5
Fixes
nolannbiron e582e6c
Fixes + handle aiChat enabled or not
nolannbiron 334b913
loading indicator
nolannbiron 1e1fb4c
Copied state
nolannbiron 32246b1
quick chore
nolannbiron c241a83
Update dropdown
nolannbiron 26a6c2e
fixes icons
nolannbiron 1267f98
changeset
nolannbiron 4935c5e
Fixes after review
nolannbiron 6468a4b
Rebase
nolannbiron a0c6ad0
Fix markdownPageUrl
nolannbiron 1b4d439
Copy feedback
nolannbiron df2c1f6
Translations + mobile
nolannbiron 41d1783
Loading indicator for docs assistant
nolannbiron ee3bb9d
Chores before merge
nolannbiron 458c4c3
Fixes
nolannbiron 23ac0e4
Update mobile title font-size
nolannbiron 1a19299
Remove leading none
nolannbiron 471d89a
Align DropdownMenu and Button styles
nolannbiron File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'gitbook': minor | ||
--- | ||
|
||
Implement AI actions dropdown |
268 changes: 268 additions & 0 deletions
268
packages/gitbook/src/components/AIActions/AIActions.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
'use client'; | ||
|
||
import { useAIChatController } from '@/components/AI/useAIChat'; | ||
import { useAIChatState } from '@/components/AI/useAIChat'; | ||
import { ChatGPTIcon } from '@/components/AIActions/assets/ChatGPTIcon'; | ||
import { ClaudeIcon } from '@/components/AIActions/assets/ClaudeIcon'; | ||
import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon'; | ||
import AIChatIcon from '@/components/AIChat/AIChatIcon'; | ||
import { Button } from '@/components/primitives/Button'; | ||
import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; | ||
import { tString, useLanguage } from '@/intl/client'; | ||
import type { TranslationLanguage } from '@/intl/translations'; | ||
import { Icon, type IconName, IconStyle } from '@gitbook/icons'; | ||
import assertNever from 'assert-never'; | ||
import type React from 'react'; | ||
import { useEffect, useRef } from 'react'; | ||
import { create } from 'zustand'; | ||
|
||
type AIActionType = 'button' | 'dropdown-menu-item'; | ||
|
||
/** | ||
* Opens our AI Docs Assistant. | ||
*/ | ||
export function OpenDocsAssistant(props: { type: AIActionType }) { | ||
const { type } = props; | ||
const chatController = useAIChatController(); | ||
const chat = useAIChatState(); | ||
const language = useLanguage(); | ||
|
||
return ( | ||
<AIActionWrapper | ||
type={type} | ||
icon={<AIChatIcon state={chat.loading ? 'thinking' : 'default'} />} | ||
label={tString(language, 'ai_chat_ask', tString(language, 'ai_chat_assistant_name'))} | ||
shortLabel={tString(language, 'ask')} | ||
description={tString( | ||
language, | ||
'ai_chat_ask_about_page', | ||
tString(language, 'ai_chat_assistant_name') | ||
)} | ||
disabled={chat.loading} | ||
onClick={() => { | ||
// Open the chat if it's not already open | ||
if (!chat.opened) { | ||
chatController.open(); | ||
} | ||
|
||
// Send the "What is this page about?" message | ||
chatController.postMessage({ | ||
message: tString(language, 'ai_chat_suggested_questions_about_this_page'), | ||
}); | ||
}} | ||
/> | ||
); | ||
} | ||
|
||
// We need to store the copied state in a store to share the state between the | ||
// copy button and the dropdown menu item. | ||
const useCopiedStore = create<{ | ||
copied: boolean; | ||
setCopied: (copied: boolean) => void; | ||
}>((set) => ({ | ||
copied: false, | ||
setCopied: (copied: boolean) => set({ copied }), | ||
})); | ||
|
||
/** | ||
* Copies the markdown version of the page to the clipboard. | ||
*/ | ||
export function CopyMarkdown(props: { | ||
markdown: string; | ||
type: AIActionType; | ||
isDefaultAction?: boolean; | ||
}) { | ||
const { markdown, type, isDefaultAction } = props; | ||
const language = useLanguage(); | ||
const { copied, setCopied } = useCopiedStore(); | ||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
|
||
// Close the dropdown menu manually after the copy button is clicked | ||
const closeDropdownMenu = () => { | ||
const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]'); | ||
|
||
// Cancel if no dropdown menu is open | ||
if (!dropdownMenu) return; | ||
|
||
// Dispatch on `document` so that the event is captured by Radix's | ||
// dismissable-layer listener regardless of focus location. | ||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); | ||
}; | ||
|
||
useEffect(() => { | ||
return () => { | ||
if (timeoutRef.current) { | ||
clearTimeout(timeoutRef.current); | ||
} | ||
}; | ||
}, []); | ||
|
||
const onClick = (e: React.MouseEvent) => { | ||
// Prevent default behavior for non-default actions to avoid closing the dropdown. | ||
// This allows showing transient UI (e.g., a "copied" state) inside the menu item. | ||
// Default action buttons are excluded from this behavior. | ||
if (!isDefaultAction) { | ||
e.preventDefault(); | ||
} | ||
|
||
// Cancel any pending timeout | ||
if (timeoutRef.current) { | ||
clearTimeout(timeoutRef.current); | ||
} | ||
|
||
navigator.clipboard.writeText(markdown); | ||
|
||
setCopied(true); | ||
|
||
// Reset the copied state after 2 seconds | ||
timeoutRef.current = setTimeout(() => { | ||
// Close the dropdown menu if it's a dropdown menu item and not the default action | ||
if (type === 'dropdown-menu-item' && !isDefaultAction) { | ||
closeDropdownMenu(); | ||
} | ||
|
||
setCopied(false); | ||
}, 2000); | ||
}; | ||
|
||
return ( | ||
<AIActionWrapper | ||
type={type} | ||
icon={copied ? 'check' : 'copy'} | ||
label={copied ? tString(language, 'code_copied') : tString(language, 'copy_page')} | ||
description={tString(language, 'copy_page_markdown')} | ||
onClick={onClick} | ||
/> | ||
); | ||
} | ||
|
||
/** | ||
* Redirects to the markdown version of the page. | ||
*/ | ||
export function ViewAsMarkdown(props: { markdownPageUrl: string; type: AIActionType }) { | ||
const { markdownPageUrl, type } = props; | ||
const language = useLanguage(); | ||
|
||
return ( | ||
<AIActionWrapper | ||
type={type} | ||
icon={<MarkdownIcon className="size-4 fill-current" />} | ||
label={tString(language, 'view_page_markdown')} | ||
description={tString(language, 'view_page_plaintext')} | ||
href={`${markdownPageUrl}.md`} | ||
/> | ||
); | ||
} | ||
|
||
/** | ||
* Open the page in a LLM with a pre-filled prompt. Either ChatGPT or Claude. | ||
*/ | ||
export function OpenInLLM(props: { | ||
provider: 'chatgpt' | 'claude'; | ||
url: string; | ||
type: AIActionType; | ||
}) { | ||
const { provider, url, type } = props; | ||
const language = useLanguage(); | ||
|
||
const providerLabel = provider === 'chatgpt' ? 'ChatGPT' : 'Claude'; | ||
|
||
return ( | ||
<AIActionWrapper | ||
type={type} | ||
icon={ | ||
provider === 'chatgpt' ? ( | ||
<ChatGPTIcon className="size-3.5 fill-current" /> | ||
) : ( | ||
<ClaudeIcon className="size-3.5 fill-current" /> | ||
) | ||
} | ||
label={tString(language, 'open_in', providerLabel)} | ||
shortLabel={providerLabel} | ||
description={tString(language, 'ai_chat_ask_about_page', providerLabel)} | ||
href={getLLMURL(provider, url, language)} | ||
/> | ||
); | ||
} | ||
|
||
/** | ||
* Wraps an action in a button (for the default action) or dropdown menu item. | ||
*/ | ||
function AIActionWrapper(props: { | ||
type: AIActionType; | ||
icon: IconName | React.ReactNode; | ||
label: string; | ||
/** | ||
* The label to display in the button. If not provided, the `label` will be used. | ||
*/ | ||
shortLabel?: string; | ||
onClick?: (e: React.MouseEvent) => void; | ||
description?: string; | ||
href?: string; | ||
disabled?: boolean; | ||
}) { | ||
const { type, icon, label, shortLabel, onClick, href, description, disabled } = props; | ||
|
||
if (type === 'button') { | ||
return ( | ||
<Button | ||
icon={icon} | ||
size="small" | ||
variant="secondary" | ||
label={shortLabel || label} | ||
className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm" | ||
onClick={onClick} | ||
href={href} | ||
target={href ? '_blank' : undefined} | ||
disabled={disabled} | ||
/> | ||
); | ||
} | ||
|
||
return ( | ||
<DropdownMenuItem | ||
className="flex items-stretch gap-2.5 p-2" | ||
href={href} | ||
target="_blank" | ||
onClick={onClick} | ||
disabled={disabled} | ||
> | ||
{icon ? ( | ||
<div className="flex size-5 items-center justify-center text-tint"> | ||
{typeof icon === 'string' ? ( | ||
<Icon | ||
icon={icon as IconName} | ||
iconStyle={IconStyle.Regular} | ||
className="size-4 fill-transparent stroke-current" | ||
/> | ||
) : ( | ||
icon | ||
)} | ||
</div> | ||
) : null} | ||
<div className="flex flex-1 flex-col gap-0.5"> | ||
<span className="flex items-center gap-2 text-tint-strong"> | ||
<span className="truncate font-medium text-sm">{label}</span> | ||
{href ? <Icon icon="arrow-up-right" className="size-3" /> : null} | ||
</span> | ||
{description && <span className="truncate text-tint text-xs">{description}</span>} | ||
</div> | ||
</DropdownMenuItem> | ||
); | ||
} | ||
|
||
/** | ||
* Returns the URL to open the page in a LLM with a pre-filled prompt. | ||
*/ | ||
function getLLMURL(provider: 'chatgpt' | 'claude', url: string, language: TranslationLanguage) { | ||
const prompt = encodeURIComponent(tString(language, 'open_in_llms_pre_prompt', url)); | ||
|
||
switch (provider) { | ||
case 'chatgpt': | ||
return `https://chat.openai.com/?q=${prompt}`; | ||
case 'claude': | ||
return `https://claude.ai/new?q=${prompt}`; | ||
default: | ||
assertNever(provider); | ||
} | ||
} |
103 changes: 103 additions & 0 deletions
103
packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
'use client'; | ||
|
||
import { | ||
CopyMarkdown, | ||
OpenDocsAssistant, | ||
OpenInLLM, | ||
ViewAsMarkdown, | ||
} from '@/components/AIActions/AIActions'; | ||
import { Button } from '@/components/primitives/Button'; | ||
import { DropdownMenu } from '@/components/primitives/DropdownMenu'; | ||
import { useRef } from 'react'; | ||
|
||
/** | ||
* Dropdown menu for the AI Actions (Ask Docs Assistant, Copy page, View as Markdown, Open in LLM). | ||
*/ | ||
export function AIActionsDropdown(props: { | ||
markdown?: string; | ||
markdownPageUrl: string; | ||
/** | ||
* Whether to include the "Ask Docs Assistant" entry in the dropdown menu. | ||
*/ | ||
withAIChat?: boolean; | ||
pageURL: string; | ||
}) { | ||
const ref = useRef<HTMLDivElement>(null); | ||
|
||
return ( | ||
<div ref={ref} className="hidden h-fit items-stretch justify-start sm:flex"> | ||
<DefaultAction {...props} /> | ||
<DropdownMenu | ||
align="end" | ||
className="!min-w-60 max-w-max" | ||
button={ | ||
<Button | ||
icon="chevron-down" | ||
iconOnly | ||
size="small" | ||
variant="secondary" | ||
className="hover:!scale-100 !shadow-none !rounded-l-none bg-tint-base text-sm" | ||
/> | ||
} | ||
> | ||
<AIActionsDropdownMenuContent {...props} /> | ||
</DropdownMenu> | ||
</div> | ||
); | ||
} | ||
|
||
/** | ||
* The content of the dropdown menu. | ||
*/ | ||
function AIActionsDropdownMenuContent(props: { | ||
markdown?: string; | ||
markdownPageUrl: string; | ||
withAIChat?: boolean; | ||
pageURL: string; | ||
}) { | ||
const { markdown, markdownPageUrl, withAIChat, pageURL } = props; | ||
|
||
return ( | ||
<> | ||
{withAIChat ? <OpenDocsAssistant type="dropdown-menu-item" /> : null} | ||
{markdown ? ( | ||
<> | ||
<CopyMarkdown | ||
markdown={markdown} | ||
isDefaultAction={!withAIChat} | ||
type="dropdown-menu-item" | ||
/> | ||
<ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="dropdown-menu-item" /> | ||
</> | ||
) : null} | ||
<OpenInLLM provider="chatgpt" url={pageURL} type="dropdown-menu-item" /> | ||
<OpenInLLM provider="claude" url={pageURL} type="dropdown-menu-item" /> | ||
</> | ||
); | ||
} | ||
|
||
/** | ||
* A default action shown as a quick-access button beside the dropdown menu | ||
*/ | ||
function DefaultAction(props: { | ||
markdown?: string; | ||
withAIChat?: boolean; | ||
pageURL: string; | ||
markdownPageUrl: string; | ||
}) { | ||
const { markdown, withAIChat, pageURL, markdownPageUrl } = props; | ||
|
||
if (withAIChat) { | ||
return <OpenDocsAssistant type="button" />; | ||
} | ||
|
||
if (markdown) { | ||
return <CopyMarkdown isDefaultAction={!withAIChat} markdown={markdown} type="button" />; | ||
} | ||
|
||
if (markdownPageUrl) { | ||
return <ViewAsMarkdown markdownPageUrl={markdownPageUrl} type="button" />; | ||
} | ||
|
||
return <OpenInLLM provider="chatgpt" url={pageURL} type="button" />; | ||
} |
8 changes: 8 additions & 0 deletions
8
packages/gitbook/src/components/AIActions/assets/ChatGPTIcon.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
export function ChatGPTIcon(props: React.SVGProps<SVGSVGElement>) { | ||
return ( | ||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" {...props}> | ||
<title>ChatGPT</title> | ||
<path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" /> | ||
</svg> | ||
); | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.