| import React, { useState, useRef } from 'react'; |
| import { useRecoilState } from 'recoil'; |
| import * as Ariakit from '@ariakit/react'; |
| import { VisuallyHidden } from '@ariakit/react'; |
| import { GitFork, InfoIcon } from 'lucide-react'; |
| import { useToastContext } from '@librechat/client'; |
| import { ForkOptions } from 'librechat-data-provider'; |
| import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react'; |
| import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks'; |
| import { useForkConvoMutation } from '~/data-provider'; |
| import { cn } from '~/utils'; |
| import store from '~/store'; |
|
|
| interface PopoverButtonProps { |
| children: React.ReactNode; |
| setting: ForkOptions; |
| onClick: (setting: ForkOptions) => void; |
| setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>; |
| timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>; |
| hoverInfo?: React.ReactNode | string; |
| hoverTitle?: React.ReactNode | string; |
| hoverDescription?: React.ReactNode | string; |
| label: string; |
| } |
|
|
| const optionLabels: Record<ForkOptions, TranslationKeys> = { |
| [ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible', |
| [ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches', |
| [ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target', |
| [ForkOptions.DEFAULT]: 'com_ui_fork_from_message', |
| }; |
|
|
| const chevronDown = ( |
| <svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor"> |
| <path |
| fillRule="evenodd" |
| clipRule="evenodd" |
| d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" |
| /> |
| </svg> |
| ); |
|
|
| const PopoverButton: React.FC<PopoverButtonProps> = ({ |
| children, |
| setting, |
| onClick, |
| setActiveSetting, |
| timeoutRef, |
| hoverInfo, |
| hoverTitle, |
| hoverDescription, |
| label, |
| }) => { |
| const localize = useLocalize(); |
|
|
| return ( |
| <Ariakit.HovercardProvider placement="right-start"> |
| <div className="flex flex-col items-center"> |
| <Ariakit.HovercardAnchor |
| render={ |
| <Ariakit.Button |
| onClick={() => onClick(setting)} |
| onMouseEnter={() => { |
| if (timeoutRef.current) { |
| clearTimeout(timeoutRef.current); |
| timeoutRef.current = null; |
| } |
| setActiveSetting(optionLabels[setting]); |
| }} |
| onMouseLeave={() => { |
| if (timeoutRef.current) { |
| clearTimeout(timeoutRef.current); |
| } |
| timeoutRef.current = setTimeout(() => { |
| setActiveSetting(optionLabels[ForkOptions.DEFAULT]); |
| }, 175); |
| }} |
| className="mx-0.5 w-14 flex-1 rounded-xl border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-200 ease-in-out hover:bg-surface-hover hover:text-text-primary" |
| aria-label={label} |
| > |
| {children} |
| <VisuallyHidden>{label}</VisuallyHidden> |
| </Ariakit.Button> |
| } |
| /> |
| <Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"> |
| <VisuallyHidden> |
| {localize('com_ui_fork_more_details_about', { 0: label })} |
| </VisuallyHidden> |
| {chevronDown} |
| </Ariakit.HovercardDisclosure> |
| {((hoverInfo != null && hoverInfo !== '') || |
| (hoverTitle != null && hoverTitle !== '') || |
| (hoverDescription != null && hoverDescription !== '')) && ( |
| <Ariakit.Hovercard |
| gutter={16} |
| shift={40} |
| flip={false} |
| className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md" |
| portal={true} |
| unmountOnHide={true} |
| > |
| <div className="space-y-2"> |
| <p className="flex flex-col gap-2 text-sm text-text-secondary"> |
| {hoverInfo && hoverInfo} |
| {hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>} |
| {hoverDescription && hoverDescription} |
| </p> |
| </div> |
| </Ariakit.Hovercard> |
| )} |
| </div> |
| </Ariakit.HovercardProvider> |
| ); |
| }; |
|
|
| interface CheckboxOptionProps { |
| id: string; |
| checked: boolean; |
| onToggle: (checked: boolean) => void; |
| labelKey: TranslationKeys; |
| infoKey: TranslationKeys; |
| showToastOnCheck?: boolean; |
| } |
| const CheckboxOption: React.FC<CheckboxOptionProps> = ({ |
| id, |
| checked, |
| onToggle, |
| labelKey, |
| infoKey, |
| showToastOnCheck = false, |
| }) => { |
| const localize = useLocalize(); |
| const { showToast } = useToastContext(); |
| return ( |
| <Ariakit.HovercardProvider placement="right-start"> |
| <div className="flex items-center"> |
| <div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"> |
| <Ariakit.HovercardAnchor |
| render={ |
| <div> |
| <Ariakit.Checkbox |
| id={id} |
| checked={checked} |
| onChange={(e) => { |
| const value = e.target.checked; |
| if (value && showToastOnCheck) { |
| showToast({ |
| message: localize('com_ui_fork_remember_checked'), |
| status: 'info', |
| }); |
| } |
| onToggle(value); |
| }} |
| className="h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground" |
| aria-label={localize(labelKey)} |
| /> |
| <label htmlFor={id} className="ml-2 cursor-pointer"> |
| {localize(labelKey)} |
| </label> |
| </div> |
| } |
| /> |
| </div> |
| <Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"> |
| <VisuallyHidden>{localize(infoKey)}</VisuallyHidden> |
| {chevronDown} |
| </Ariakit.HovercardDisclosure> |
| </div> |
| <Ariakit.Hovercard |
| gutter={14} |
| shift={40} |
| flip={false} |
| className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md" |
| portal={true} |
| unmountOnHide={true} |
| > |
| <div className="space-y-2"> |
| <p className="text-sm text-text-secondary">{localize(infoKey)}</p> |
| </div> |
| </Ariakit.Hovercard> |
| </Ariakit.HovercardProvider> |
| ); |
| }; |
|
|
| export default function Fork({ |
| messageId, |
| conversationId: _convoId, |
| forkingSupported = false, |
| latestMessageId, |
| isLast = false, |
| }: { |
| messageId: string; |
| conversationId: string | null; |
| forkingSupported?: boolean; |
| latestMessageId?: string; |
| isLast?: boolean; |
| }) { |
| const localize = useLocalize(); |
| const { showToast } = useToastContext(); |
| const [remember, setRemember] = useState(false); |
| const { navigateToConvo } = useNavigateToConvo(); |
| const [isActive, setIsActive] = useState(false); |
| const timeoutRef = useRef<NodeJS.Timeout | null>(null); |
| const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting); |
| const [activeSetting, setActiveSetting] = useState(optionLabels.default); |
| const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget); |
| const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork); |
| const popoverStore = Ariakit.usePopoverStore({ |
| placement: 'bottom', |
| }); |
|
|
| const buttonStyle = cn( |
| 'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200', |
| 'hover:text-text-primary hover:bg-surface-hover', |
| 'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible', |
| !isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100', |
| 'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none', |
| isActive && 'active text-text-primary bg-surface-hover', |
| ); |
|
|
| const forkConvo = useForkConvoMutation({ |
| onSuccess: (data) => { |
| navigateToConvo(data.conversation); |
| showToast({ |
| message: localize('com_ui_fork_success'), |
| status: 'success', |
| }); |
| }, |
| onMutate: () => { |
| showToast({ |
| message: localize('com_ui_fork_processing'), |
| status: 'info', |
| }); |
| }, |
| onError: (error) => { |
| |
| const isRateLimitError = |
| (error as any)?.response?.status === 429 || |
| (error as any)?.status === 429 || |
| (error as any)?.statusCode === 429; |
|
|
| showToast({ |
| message: isRateLimitError |
| ? localize('com_ui_fork_error_rate_limit') |
| : localize('com_ui_fork_error'), |
| status: 'error', |
| }); |
| }, |
| }); |
|
|
| const conversationId = _convoId ?? ''; |
| if (!forkingSupported || !conversationId || !messageId) { |
| return null; |
| } |
|
|
| const onClick = (option: string) => { |
| if (remember) { |
| setRememberGlobal(true); |
| setForkSetting(option); |
| } |
|
|
| forkConvo.mutate({ |
| messageId, |
| conversationId, |
| option, |
| splitAtTarget, |
| latestMessageId, |
| }); |
| }; |
|
|
| const forkOptionsConfig = [ |
| { |
| setting: ForkOptions.DIRECT_PATH, |
| label: localize(optionLabels[ForkOptions.DIRECT_PATH]), |
| icon: <GitCommit className="h-full w-full rotate-90 p-2" />, |
| hoverTitle: ( |
| <> |
| <GitCommit className="h-5 w-5 rotate-90" /> |
| {localize(optionLabels[ForkOptions.DIRECT_PATH])} |
| </> |
| ), |
| hoverDescription: localize('com_ui_fork_info_visible'), |
| }, |
| { |
| setting: ForkOptions.INCLUDE_BRANCHES, |
| label: localize(optionLabels[ForkOptions.INCLUDE_BRANCHES]), |
| icon: <GitBranchPlus className="h-full w-full rotate-180 p-2" />, |
| hoverTitle: ( |
| <> |
| <GitBranchPlus className="h-4 w-4 rotate-180" /> |
| {localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])} |
| </> |
| ), |
| hoverDescription: localize('com_ui_fork_info_branches'), |
| }, |
| { |
| setting: ForkOptions.TARGET_LEVEL, |
| label: localize(optionLabels[ForkOptions.TARGET_LEVEL]), |
| icon: <ListTree className="h-full w-full p-2" />, |
| hoverTitle: ( |
| <> |
| <ListTree className="h-5 w-5" /> |
| {`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize('com_endpoint_default')})`} |
| </> |
| ), |
| hoverDescription: localize('com_ui_fork_info_target'), |
| }, |
| ]; |
|
|
| return ( |
| <> |
| <Ariakit.PopoverAnchor |
| store={popoverStore} |
| render={ |
| <button |
| className={buttonStyle} |
| onClick={(e) => { |
| if (rememberGlobal) { |
| e.preventDefault(); |
| forkConvo.mutate({ |
| messageId, |
| splitAtTarget, |
| conversationId, |
| option: forkSetting, |
| latestMessageId, |
| }); |
| } else { |
| popoverStore.toggle(); |
| setIsActive(popoverStore.getState().open); |
| } |
| }} |
| type="button" |
| aria-label={localize('com_ui_fork')} |
| > |
| <GitFork size="19" /> |
| </button> |
| } |
| /> |
| <Ariakit.Popover |
| store={popoverStore} |
| gutter={10} |
| className={`popover-animate ${isActive ? 'open' : ''} flex w-60 flex-col gap-3 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-2 px-4 shadow-lg`} |
| style={{ |
| outline: 'none', |
| pointerEvents: 'auto', |
| zIndex: 50, |
| }} |
| portal={true} |
| unmountOnHide={true} |
| onClose={() => setIsActive(false)} |
| > |
| <div className="flex h-8 w-full items-center justify-center text-sm text-text-primary"> |
| {localize(activeSetting)} |
| <Ariakit.HovercardProvider placement="right-start"> |
| <div className="ml-auto flex h-6 w-6 items-center justify-center gap-1"> |
| <Ariakit.HovercardAnchor |
| render={ |
| <button |
| className="flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary" |
| aria-label={localize('com_ui_fork_info_button_label')} |
| > |
| <InfoIcon /> |
| </button> |
| } |
| /> |
| <Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"> |
| <VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden> |
| {chevronDown} |
| </Ariakit.HovercardDisclosure> |
| </div> |
| <Ariakit.Hovercard |
| gutter={19} |
| shift={40} |
| flip={false} |
| className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md" |
| portal={true} |
| unmountOnHide={true} |
| > |
| <div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary"> |
| <span>{localize('com_ui_fork_info_1')}</span> |
| <span>{localize('com_ui_fork_info_2')}</span> |
| <span> |
| {localize('com_ui_fork_info_3', { |
| 0: localize('com_ui_fork_split_target'), |
| })} |
| </span> |
| </div> |
| </Ariakit.Hovercard> |
| </Ariakit.HovercardProvider> |
| </div> |
| <div className="flex h-full w-full items-center justify-center gap-1"> |
| {forkOptionsConfig.map((opt) => ( |
| <PopoverButton |
| key={opt.setting} |
| setActiveSetting={setActiveSetting} |
| timeoutRef={timeoutRef} |
| onClick={onClick} |
| setting={opt.setting} |
| label={opt.label} |
| hoverTitle={opt.hoverTitle} |
| hoverDescription={opt.hoverDescription} |
| > |
| {opt.icon} |
| </PopoverButton> |
| ))} |
| </div> |
| <CheckboxOption |
| id="split-target-checkbox" |
| checked={splitAtTarget} |
| onToggle={setSplitAtTarget} |
| labelKey="com_ui_fork_split_target" |
| infoKey="com_ui_fork_info_start" |
| /> |
| <CheckboxOption |
| id="remember-checkbox" |
| checked={remember} |
| onToggle={(checked) => { |
| if (checked) |
| showToast({ message: localize('com_ui_fork_remember_checked'), status: 'info' }); |
| setRemember(checked); |
| }} |
| labelKey="com_ui_fork_remember" |
| infoKey="com_ui_fork_info_remember" |
| showToastOnCheck |
| /> |
| </Ariakit.Popover> |
| </> |
| ); |
| } |
|
|