-
Notifications
You must be signed in to change notification settings - Fork 10
docs(copyButton): Add LLM question feature to the document copy button #470
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
base: main
Are you sure you want to change the base?
Changes from all commits
5939efc
b980088
e0f3129
40c9a82
64c3e9a
891e448
5dc3c4f
bba12f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| export const AnthropicIcon = () => ( | ||
| <svg | ||
| fill="currentColor" | ||
| fillRule="evenodd" | ||
| height={16} | ||
| width={16} | ||
| viewBox="0 0 24 24" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z" /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export const OpenAIIcon = () => ( | ||
| <svg | ||
| fill="currentColor" | ||
| fillRule="evenodd" | ||
| height={16} | ||
| width={16} | ||
| viewBox="0 0 24 24" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z" /> | ||
| </svg> | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,38 +1,177 @@ | ||
| 'use client'; | ||
|
|
||
| import { Button } from '@vapor-ui/core'; | ||
| import { CopyAsMarkdownOutlineIcon, CopyIcon } from '@vapor-ui/icons'; | ||
| import { useMemo, useState } from 'react'; | ||
|
|
||
| import { Button, HStack, IconButton, Menu } from '@vapor-ui/core'; | ||
| import { | ||
| ChevronDownOutlineIcon, | ||
| ConfirmOutlineIcon, | ||
| CopyAsMarkdownOutlineIcon, | ||
| OpenInNewOutlineIcon, | ||
| } from '@vapor-ui/icons'; | ||
| import { track } from '@vercel/analytics'; | ||
| import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'; | ||
|
|
||
| import { | ||
| COPY_BUTTON_ACTIONS, | ||
| type CopyButtonAction, | ||
| createCopyButtonEventName, | ||
| } from '~/constants/analytics'; | ||
|
|
||
| import { AnthropicIcon, OpenAIIcon } from './copy-button.icons'; | ||
|
|
||
| const markdownIcon = <CopyAsMarkdownOutlineIcon width={16} height={16} />; | ||
| const anthropicIcon = <AnthropicIcon />; | ||
| const openAIIcon = <OpenAIIcon />; | ||
|
|
||
| const cache = new Map<string, string>(); | ||
|
|
||
| type CopyButtonProps = Omit<Button.Props, 'onClick' | 'disabled'> & { | ||
| const TRUSTED_DOMAINS = ['vapor-ui.goorm.io', 'localhost'] as const; | ||
|
|
||
| const isValidMarkdownUrl = (url: string): boolean => { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return TRUSTED_DOMAINS.some( | ||
| (domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`), | ||
| ); | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| type CopyButtonProps = { | ||
| markdownUrl: string; | ||
| }; | ||
|
|
||
| export const CopyButton = ({ markdownUrl, ...props }: CopyButtonProps) => { | ||
| const handleCopyContent = async (url: string) => { | ||
| const cached = cache.get(url); | ||
| if (cached) return navigator.clipboard.writeText(cached); | ||
|
|
||
| await navigator.clipboard.write([ | ||
| new ClipboardItem({ | ||
| 'text/plain': fetch(url).then(async (res) => { | ||
| const content = await res.text(); | ||
| cache.set(url, content); | ||
|
|
||
| return content; | ||
| }), | ||
| }), | ||
| ]); | ||
| const LLM_URLS = { | ||
| claude: 'https://claude.ai/new', | ||
| chatgpt: 'https://chat.openai.com/', | ||
| } as const; | ||
|
|
||
| const LLM_PROMPT_MESSAGE = ' 문서를 읽고 질문에 답해줘.'; | ||
|
|
||
| const openLLMChat = (llmType: keyof typeof LLM_URLS, docUrl: string) => { | ||
| if (!isValidMarkdownUrl(docUrl)) { | ||
| console.error('Invalid doc URL:', docUrl); | ||
| return; | ||
| } | ||
|
|
||
| const prompt = encodeURIComponent(`${docUrl}${LLM_PROMPT_MESSAGE}`); | ||
| const url = `${LLM_URLS[llmType]}?q=${prompt}`; | ||
| window.open(url, '_blank'); | ||
| }; | ||
|
|
||
| const trackCopyButtonEvent = (action: CopyButtonAction, markdownUrl: string) => { | ||
| const eventName = createCopyButtonEventName(action, markdownUrl); | ||
| track(eventName); | ||
| }; | ||
|
|
||
| export const CopyButton = ({ markdownUrl }: CopyButtonProps) => { | ||
| const [checked, onCopy] = useCopyButton(() => handleCopyContent()); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
|
|
||
| const handleCopyContent = async () => { | ||
| trackCopyButtonEvent(COPY_BUTTON_ACTIONS.COPY_MARKDOWN, markdownUrl); | ||
|
|
||
| const cached = cache.get(markdownUrl); | ||
| if (cached) { | ||
| await navigator.clipboard.writeText(cached); | ||
| return; | ||
| } | ||
|
|
||
| if (!isValidMarkdownUrl(markdownUrl)) { | ||
| console.error('Invalid markdown URL:', markdownUrl); | ||
| return; | ||
| } | ||
|
|
||
| setIsLoading(true); | ||
| try { | ||
| const res = await fetch(markdownUrl); | ||
| const content = await res.text(); | ||
| cache.set(markdownUrl, content); | ||
| await navigator.clipboard.writeText(content); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| const [checked, onClick] = useCopyButton(() => handleCopyContent(markdownUrl)); | ||
| const menuItems = useMemo( | ||
| () => [ | ||
| { | ||
| label: '마크다운으로 보기', | ||
| icon: markdownIcon, | ||
| onClick: () => { | ||
| if (isValidMarkdownUrl(markdownUrl)) { | ||
| trackCopyButtonEvent(COPY_BUTTON_ACTIONS.VIEW_MARKDOWN, markdownUrl); | ||
| window.open(markdownUrl, '_blank'); | ||
| } | ||
| }, | ||
|
Comment on lines
+103
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The "View in Markdown" item cannot be tracked when clicked. However, the other two elements trigger event tracking when clicked, even if no action occurs. This issue arises due to differences in validation locations using |
||
| isExternal: true, | ||
| }, | ||
| { | ||
| label: 'Claude에게 질문하기', | ||
| icon: anthropicIcon, | ||
| onClick: () => { | ||
| trackCopyButtonEvent(COPY_BUTTON_ACTIONS.ASK_CLAUDE, markdownUrl); | ||
| openLLMChat('claude', markdownUrl); | ||
| }, | ||
| isExternal: true, | ||
| }, | ||
| { | ||
| label: 'ChatGPT에게 질문하기', | ||
| icon: openAIIcon, | ||
| onClick: () => { | ||
| trackCopyButtonEvent(COPY_BUTTON_ACTIONS.ASK_CHATGPT, markdownUrl); | ||
| openLLMChat('chatgpt', markdownUrl); | ||
| }, | ||
| isExternal: true, | ||
| }, | ||
| ], | ||
| [markdownUrl], | ||
| ); | ||
|
Comment on lines
+98
to
+131
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Therefore, I propose the following structure to separate the MenuItem handler from the actions of the buttons: const handleMenuItemClick = (action: CopyButtonAction, handler: () => void) => {
if (!isValidMarkdownUrl(markdownUrl)) return;
trackCopyButtonEvent(action, markdownUrl);
handler();
};
const menuItems = useMemo(
() => [
{
label: '마크다운으로 보기',
icon: markdownIcon,
action: COPY_BUTTON_ACTIONS.VIEW_MARKDOWN,
handler: () => window.open(markdownUrl, '_blank'),
isExternal: true,
},
{
label: 'Claude에게 질문하기',
icon: anthropicIcon,
action: COPY_BUTTON_ACTIONS.ASK_CLAUDE,
handler: () => openLLMChat('claude', markdownUrl),
isExternal: true,
},
{
label: 'ChatGPT에게 질문하기',
icon: openAIIcon,
action: COPY_BUTTON_ACTIONS.ASK_CHATGPT,
handler: () => openLLMChat('chatgpt', markdownUrl),
isExternal: true,
},
],
[markdownUrl],
);
// ...
{
menuItems.map(({ label, icon, action, handler, isExternal }) => (
<Menu.Item key={label} onClick={() => handleMenuItemClick(action, handler)}>
{icon}
{label}
{isExternal && <OpenInNewOutlineIcon width={16} height={16} className="ml-auto" />}
</Menu.Item>
));
}This approach should also maintain the consistency of the validation logic mentioned earlier. |
||
|
|
||
| return ( | ||
| <Button colorPalette="secondary" variant="outline" onClick={onClick} {...props}> | ||
| {checked ? <CopyAsMarkdownOutlineIcon /> : <CopyIcon />} | ||
| Copy as Markdown | ||
| </Button> | ||
| <HStack | ||
| gap="0" | ||
| className="rounded-md shadow-[inset_0_0_0_1px_var(--vapor-color-border-secondary)] w-fit" | ||
| > | ||
|
Comment on lines
+134
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding |
||
| <Button | ||
| colorPalette="secondary" | ||
| variant="ghost" | ||
| onClick={onCopy} | ||
| disabled={isLoading} | ||
| className="rounded-r-none" | ||
| > | ||
| {checked ? <ConfirmOutlineIcon /> : <CopyAsMarkdownOutlineIcon />} | ||
| 마크다운 복사 | ||
| </Button> | ||
| <span className="w-px bg-v-gray-200 self-stretch" /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This element is used as a separator, so adding |
||
| <Menu.Root> | ||
| <Menu.Trigger | ||
| render={ | ||
| <IconButton | ||
| colorPalette="secondary" | ||
| variant="ghost" | ||
| aria-label="더보기" | ||
| disabled={isLoading} | ||
| className="rounded-l-none" | ||
| /> | ||
| } | ||
| > | ||
| <ChevronDownOutlineIcon /> | ||
| </Menu.Trigger> | ||
| <Menu.Popup className="min-w-[200px]"> | ||
| {menuItems.map((item) => ( | ||
| <Menu.Item key={item.label} onClick={item.onClick}> | ||
| {item.icon} | ||
| {item.label} | ||
| {item.isExternal && ( | ||
| <OpenInNewOutlineIcon width={16} height={16} className="ml-auto" /> | ||
| )} | ||
| </Menu.Item> | ||
| ))} | ||
| </Menu.Popup> | ||
| </Menu.Root> | ||
| </HStack> | ||
| ); | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tracking logic seems tightly coupled only to the Copy Button, and since these functions are directly called within the Copy Button to create the tracking functions, it feels like concerns aren't well separated. Event tracking could be useful elsewhere too, so it might be a good idea to refactor it over time. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| /** | ||
| * Vercel Analytics event constants and type definitions | ||
| */ | ||
|
|
||
| export const COPY_BUTTON_ACTIONS = { | ||
| COPY_MARKDOWN: 'copy_markdown', | ||
| VIEW_MARKDOWN: 'view_markdown', | ||
| ASK_CLAUDE: 'ask_claude', | ||
| ASK_CHATGPT: 'ask_chatgpt', | ||
| } as const; | ||
|
|
||
| export type CopyButtonAction = (typeof COPY_BUTTON_ACTIONS)[keyof typeof COPY_BUTTON_ACTIONS]; | ||
|
|
||
| /** | ||
| * Extracts docs path from document URL | ||
| * Example: /docs/components/navigation-menu.mdx → components/navigation-menu | ||
| */ | ||
| export const extractComponentName = (markdownUrl: string): string => { | ||
| try { | ||
| const url = new URL(markdownUrl); | ||
| const pathname = url.pathname | ||
| .split('/') | ||
| .slice(-2) | ||
| .join('/') | ||
| .replace(/\.mdx?$/, ''); | ||
| const segments = pathname.split('/').filter(Boolean); | ||
| return segments.join('/') || 'unknown'; | ||
| } catch { | ||
| return 'unknown'; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Creates CopyButton event name | ||
| * Example: copy_markdown:components/navigation-menu, ask_claude:components/button | ||
| */ | ||
| export const createCopyButtonEventName = ( | ||
| action: CopyButtonAction, | ||
| markdownUrl: string, | ||
| ): string => { | ||
| const componentName = extractComponentName(markdownUrl); | ||
| return `${action}:${componentName}`; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export const STATICS_DOMAIN = 'https://statics.goorm.io'; | ||
| export const SITE_URL = 'https://vapor-ui.goorm.io'; |
Uh oh!
There was an error while loading. Please reload this page.