mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-21 02:40:14 +01:00
🚀 feat: Shared Links (#2772)
* ✨ feat(types): add necessary types for shared link feature * ✨ feat: add shared links functions to data service Added functions for retrieving, creating, updating, and deleting shared links and shared messages. * ✨ feat: Add useGetSharedMessages hook to fetch shared messages by shareId Adds a new hook `useGetSharedMessages` which fetches shared messages based on the provided shareId. * ✨ feat: Add share schema and data access functions to API models * ✨ feat: Add share endpoint to API The GET /api/share/${shareId} is exposed to the public, so authentication is not required. Other paths require authentication. * ♻️ refactor(utils): generalize react-query cache manipulation functions Introduces generic functions for manipulating react-query cache entries, marking a refinement in how query cache data is managed. It aims to enhance the flexibility and reusability of the cache interaction patterns within our application. - Replaced specific index names with more generic terms in queries.ts, enhancing consistency across data handling functions. - Introduced new utility functions in collection.ts for adding, updating, and deleting data entries in an InfiniteData<TCollection>. These utility functions (`addData`, `updateData`, `deleteData`, `findPage`) are designed to be re-usable across different data types and collections. - Adapted existing conversation utility functions in convos.ts to leverage these new generic utilities. * ✨ feat(shared-link): add functions to manipulate shared link cache list implemented new utility functions to handle additions, updates, and deletions in the shared link cache list. * ✨ feat: Add mutations and queries for shared links * ✨ feat(shared-link): add `Share` button to conversation list - Added a share button in each conversation in the conversation list. - Implemented functionality where clicking the share button triggers a POST request to the API. - The API checks if a share link was already created for the conversation today; if so, it returns the existing link. - If no link was created for today, the API will create a new share link and return it. - Each click on the share button results in a new API request, following the specification similar to ChatGPT's share link feature. * ♻️ refactor(hooks): generalize useNavScrolling for broader use - Modified `useNavScrolling` to accept a generic type parameter `TData`, allowing it to be used with different data structures besides `ConversationListResponse`. - Updated instances in `Nav.tsx` and `ArchivedChatsTable.tsx` to explicitly specify `ConversationListResponse` as the type argument when invoking `useNavScrolling`. * ✨ feat(settings): add shared links listing table with delete functionality in settings - Integrated a delete button for each shared link in the table, allowing users to remove links as needed. * ♻️ refactor(components): separate `EndpointIcon` from `Icon` component for standalone use * ♻️ refactor: update useGetSharedMessages to return TSharedLink - Modified the useGetSharedMessages hook to return not only a list of TMessage but also the TSharedLink itself. - This change was necessary to support displaying the title and date in the Shared Message UI, which requires data from TSharedLink. * ✨ feat(shared link): add UI for displaying shared conversations without authentication - Implemented a new UI component to display shared conversations, designed to be accessible without requiring authentication. - Reused components from the authenticated Messages module where possible. Copied and adapted components that could not be directly reused to fit the non-authenticated context. * 🔧 chore: Add translations Translate labels only. Messages remain in English as they are possibly subject to change. * ♻️ refactor: add icon and tooltip props to EditMenuButton component * moved icon and popover to arguments so that EditMenuButton can be reused. * modified so that when a ShareButton is closed, the parent DropdownMenu is also closed. * ♻️irefactor: added DropdownMenu for Export and Share * ♻️ refactor: renamed component names more intuitive * More accurate naming of the dropdown menu. * When the export button is closed, the parent dropdown menu is also closed. * 🌍 chore: updated translations * 🐞 Fix: OpenID Profile Image Download (#2757) * Add fetch requirement Fixes - error: [openidStrategy] downloadImage: Error downloading image at URL "https://graph.microsoft.com/v1.0/me/photo/$value": TypeError: response.buffer is not a function * Update openidStrategy.js --------- Co-authored-by: Danny Avila <danacordially@gmail.com> * 🚑 fix(export): Issue exporting Conversation with Assistants (#2769) * 🚑 fix(export): use content as text if content is present in the message If the endpoint is assistants, the text of the message goes into content, not message.text. * refactor(ExportModel): TypeScript, remove unused code --------- Co-authored-by: Yuichi Ohneda <ohneda@gmail.com> * 📤style: export button icon (#2752) * refactor(ShareDialog): logic and styling * refactor(ExportAndShareMenu): imports order and icon update * chore: imports * chore: imports/render logic * feat: message branching * refactor: add optional config to useGetStartupConfig * refactor: disable endpoints query * chore: fix search view styling gradient in light mode * style: ShareView gradient styling * refactor(Share): use select queries * style: shared link table buttons * localization and dark text styling * style: fix clipboard button layout shift app-wide and add localization for copy code * support assistants message content in shared links, add useCopyToClipboard, add copy buttons to Search Messages and Shared Link Messages * add localizations * comparisons --------- Co-authored-by: Yuichi Ohneda <ohneda@gmail.com> Co-authored-by: bsu3338 <bsu3338@users.noreply.github.com> Co-authored-by: Fuegovic <32828263+fuegovic@users.noreply.github.com>
This commit is contained in:
parent
38ad36c1c5
commit
f0e8cca5df
78 changed files with 4683 additions and 317 deletions
63
client/src/components/Chat/ExportAndShareMenu.tsx
Normal file
63
client/src/components/Chat/ExportAndShareMenu.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import DropDownMenu from '../Conversations/DropDownMenu';
|
||||
import ShareButton from '../Conversations/ShareButton';
|
||||
import HoverToggle from '../Conversations/HoverToggle';
|
||||
import ExportButton from './ExportButton';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ExportAndShareMenu() {
|
||||
const location = useLocation();
|
||||
|
||||
const activeConvo = useRecoilValue(store.conversationByIndex(0));
|
||||
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
let conversation: TConversation | null | undefined;
|
||||
if (location.state?.from?.pathname.includes('/chat')) {
|
||||
conversation = globalConvo;
|
||||
} else {
|
||||
conversation = activeConvo;
|
||||
}
|
||||
|
||||
const exportable =
|
||||
conversation &&
|
||||
conversation.conversationId &&
|
||||
conversation.conversationId !== 'new' &&
|
||||
conversation.conversationId !== 'search';
|
||||
|
||||
if (!exportable) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const isActiveConvo = exportable;
|
||||
|
||||
return (
|
||||
<HoverToggle
|
||||
isActiveConvo={!!isActiveConvo}
|
||||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
>
|
||||
<DropDownMenu
|
||||
icon={<Upload />}
|
||||
tooltip="Export/Share"
|
||||
className="pointer-cursor relative z-50 flex h-[40px] min-w-4 flex-none flex-col items-center justify-center rounded-md border border-gray-100 bg-white px-3 text-left hover:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-offset-0 radix-state-open:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700 sm:text-sm"
|
||||
>
|
||||
{conversation && conversation.conversationId && (
|
||||
<>
|
||||
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
|
||||
<ShareButton
|
||||
conversationId={conversation.conversationId}
|
||||
title={conversation.title ?? ''}
|
||||
appendLabel={true}
|
||||
className="mb-[3.5px]"
|
||||
setPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropDownMenu>
|
||||
</HoverToggle>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +1,41 @@
|
|||
import React from 'react';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ExportModal } from '../Nav';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import store from '~/store';
|
||||
|
||||
function ExportButton() {
|
||||
function ExportButton({
|
||||
conversation,
|
||||
setPopoverActive,
|
||||
}: {
|
||||
conversation: TConversation;
|
||||
setPopoverActive: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const location = useLocation();
|
||||
|
||||
const [showExports, setShowExports] = useState(false);
|
||||
|
||||
const activeConvo = useRecoilValue(store.conversationByIndex(0));
|
||||
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
|
||||
|
||||
let conversation: TConversation | null | undefined;
|
||||
if (location.state?.from?.pathname.includes('/chat')) {
|
||||
conversation = globalConvo;
|
||||
} else {
|
||||
conversation = activeConvo;
|
||||
}
|
||||
|
||||
const clickHandler = () => {
|
||||
if (exportable) {
|
||||
setShowExports(true);
|
||||
}
|
||||
setShowExports(true);
|
||||
};
|
||||
|
||||
const exportable =
|
||||
conversation &&
|
||||
conversation.conversationId &&
|
||||
conversation.conversationId !== 'new' &&
|
||||
conversation.conversationId !== 'search';
|
||||
const onOpenChange = (value: boolean) => {
|
||||
setShowExports(value);
|
||||
setPopoverActive(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{exportable && (
|
||||
<div className="flex gap-1 gap-2 pr-1">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="btn btn-neutral btn-small relative flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<Upload size={16} />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={5}>
|
||||
{localize('com_nav_export_conversation')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
|
||||
>
|
||||
<Upload size={16} /> {localize('com_nav_export')}
|
||||
</button>
|
||||
{showExports && (
|
||||
<ExportModal open={showExports} onOpenChange={setShowExports} conversation={conversation} />
|
||||
<ExportModal open={showExports} onOpenChange={onOpenChange} conversation={conversation} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Constants } from 'librechat-data-provider';
|
|||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function Footer() {
|
||||
export default function Footer({ className }: { className?: string }) {
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const localize = useLocalize();
|
||||
|
||||
|
|
@ -52,7 +52,12 @@ export default function Footer() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center gap-2 px-2 py-2 text-xs text-gray-600 dark:text-gray-300 md:px-[60px]">
|
||||
<div
|
||||
className={
|
||||
className ||
|
||||
'relative flex items-center justify-center gap-2 px-2 py-2 text-xs text-gray-600 dark:text-gray-300 md:px-[60px]'
|
||||
}
|
||||
>
|
||||
{footerElements.map((contentRender, index) => {
|
||||
const isLastElement = index === footerElements.length - 1;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { ContextType } from '~/common';
|
|||
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
|
||||
import HeaderOptions from './Input/HeaderOptions';
|
||||
import ExportButton from './ExportButton';
|
||||
import ExportAndShareMenu from './ExportAndShareMenu';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ export default function Header() {
|
|||
{<HeaderOptions interfaceConfig={interfaceConfig} />}
|
||||
{interfaceConfig.presets && <PresetsMenu />}
|
||||
</div>
|
||||
<ExportButton />
|
||||
<ExportAndShareMenu />
|
||||
</div>
|
||||
{/* Empty div for spacing */}
|
||||
<div />
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default function HoverButtons({
|
|||
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
||||
}
|
||||
>
|
||||
{isCopied ? <CheckMark /> : <Clipboard />}
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
</button>
|
||||
{regenerateEnabled ? (
|
||||
<button
|
||||
|
|
|
|||
29
client/src/components/Chat/Messages/MinimalHoverButtons.tsx
Normal file
29
client/src/components/Chat/Messages/MinimalHoverButtons.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import { Clipboard, CheckMark } from '~/components/svg';
|
||||
|
||||
type THoverButtons = {
|
||||
message: TMessage;
|
||||
};
|
||||
|
||||
export default function MinimalHoverButtons({ message }: THoverButtons) {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyToClipboard = useCopyToClipboard({ text: message.text, content: message.content });
|
||||
|
||||
return (
|
||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
<button
|
||||
className="ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
|
||||
onClick={() => copyToClipboard(setIsCopied)}
|
||||
type="button"
|
||||
title={
|
||||
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
||||
}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
|
||||
import Icon from '~/components/Chat/Messages/MessageIcon';
|
||||
import SearchContent from './Content/SearchContent';
|
||||
import SearchButtons from './SearchButtons';
|
||||
|
|
@ -50,6 +51,7 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
|
|||
</div>
|
||||
</div>
|
||||
<SubRow classes="text-xs">
|
||||
<MinimalHoverButtons message={message} />
|
||||
<SearchButtons message={message} />
|
||||
</SubRow>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import { useConversations, useNavigateToConvo } from '~/hooks';
|
|||
import { NotificationSeverity } from '~/common';
|
||||
import { ArchiveIcon } from '~/components/svg';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import EditMenuButton from './EditMenuButton';
|
||||
import DropDownMenu from './DropDownMenu';
|
||||
import ArchiveButton from './ArchiveButton';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import RenameButton from './RenameButton';
|
||||
import HoverToggle from './HoverToggle';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
import ShareButton from './ShareButton';
|
||||
|
||||
type KeyEvent = KeyboardEvent<HTMLInputElement>;
|
||||
|
||||
|
|
@ -124,7 +125,15 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
|||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
>
|
||||
<EditMenuButton>
|
||||
<DropDownMenu>
|
||||
<ShareButton
|
||||
conversationId={conversationId}
|
||||
title={title}
|
||||
appendLabel={true}
|
||||
className="mb-[3.5px]"
|
||||
setPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
|
||||
<RenameButton
|
||||
renaming={renaming}
|
||||
onRename={onRename}
|
||||
|
|
@ -140,7 +149,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
|||
appendLabel={true}
|
||||
className="group m-1.5 mt-[3.5px] flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
|
||||
/>
|
||||
</EditMenuButton>
|
||||
</DropDownMenu>
|
||||
<ArchiveButton
|
||||
className="z-50 hover:text-black dark:hover:text-white"
|
||||
conversationId={conversationId}
|
||||
|
|
@ -156,7 +165,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
|
|||
onClick={clickHandler}
|
||||
className={cn(
|
||||
isActiveConvo || isPopoverActive
|
||||
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
|
||||
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
|
||||
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-700',
|
||||
!isActiveConvo && !renaming ? 'peer-hover:bg-gray-200 dark:peer-hover:bg-gray-800' : '',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { FC } from 'react';
|
||||
import { cloneElement, type FC } from 'react';
|
||||
import { DotsIcon } from '~/components/svg';
|
||||
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
||||
|
|
@ -6,15 +6,23 @@ import { useToggle } from './ToggleContext';
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type EditMenuButtonProps = {
|
||||
type DropDownMenuProps = {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactElement;
|
||||
tooltip?: string;
|
||||
className?: string;
|
||||
};
|
||||
const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonProps) => {
|
||||
const DropDownMenu: FC<DropDownMenuProps> = ({
|
||||
children,
|
||||
icon = <DotsIcon />,
|
||||
tooltip = 'More',
|
||||
className,
|
||||
}: DropDownMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const { setPopoverActive } = useToggle();
|
||||
const { isPopoverActive, setPopoverActive } = useToggle();
|
||||
|
||||
return (
|
||||
<Root onOpenChange={(open) => setPopoverActive(open)}>
|
||||
<Root open={isPopoverActive} onOpenChange={(open) => setPopoverActive(open)}>
|
||||
<Trigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -29,12 +37,15 @@ const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonPro
|
|||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="">
|
||||
<DotsIcon className="h-[18px] w-[18px] flex-shrink-0 text-gray-500 hover:text-gray-400 dark:text-gray-300 dark:hover:text-gray-400" />
|
||||
<button type="button" className={className}>
|
||||
{cloneElement(icon, {
|
||||
className:
|
||||
'h-[18px] w-[18px] flex-shrink-0 text-gray-500 hover:text-gray-400 dark:text-gray-300 dark:hover:text-gray-400',
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={0}>
|
||||
{localize('com_ui_more_options')}
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -57,4 +68,4 @@ const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonPro
|
|||
);
|
||||
};
|
||||
|
||||
export default EditMenuButton;
|
||||
export default DropDownMenu;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { ToggleContext } from './ToggleContext';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ const HoverToggle = ({
|
|||
}) => {
|
||||
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
|
||||
return (
|
||||
<ToggleContext.Provider value={{ setPopoverActive }}>
|
||||
<ToggleContext.Provider value={{ isPopoverActive, setPopoverActive }}>
|
||||
<div
|
||||
className={cn(
|
||||
'peer absolute bottom-0 right-0 top-0 items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',
|
||||
|
|
|
|||
113
client/src/components/Conversations/ShareButton.tsx
Normal file
113
client/src/components/Conversations/ShareButton.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
Tooltip,
|
||||
DialogTrigger,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from '~/components/ui';
|
||||
import { Share2Icon } from 'lucide-react';
|
||||
import type { TSharedLink } from 'librechat-data-provider';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import SharedLinkButton from './SharedLinkButton';
|
||||
import ShareDialog from './ShareDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ShareButton({
|
||||
conversationId,
|
||||
title,
|
||||
className,
|
||||
appendLabel = false,
|
||||
setPopoverActive,
|
||||
}: {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
className?: string;
|
||||
appendLabel?: boolean;
|
||||
setPopoverActive: (isActive: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [share, setShare] = useState<TSharedLink | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isUpdated, setIsUpdated] = useState(false);
|
||||
|
||||
const classProp: { className?: string } = {
|
||||
className: 'p-1 hover:text-black dark:hover:text-white',
|
||||
};
|
||||
if (className) {
|
||||
classProp.className = className;
|
||||
}
|
||||
const renderShareButton = () => {
|
||||
if (appendLabel) {
|
||||
return (
|
||||
<>
|
||||
<Share2Icon className="h-4 w-4" /> {localize('com_ui_share')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Share2Icon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={0}>
|
||||
{localize('com_ui_share')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const buttons = share && (
|
||||
<SharedLinkButton
|
||||
share={share}
|
||||
conversationId={conversationId}
|
||||
setShare={setShare}
|
||||
isUpdated={isUpdated}
|
||||
setIsUpdated={setIsUpdated}
|
||||
/>
|
||||
);
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
setPopoverActive(open);
|
||||
setOpen(open);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{renderShareButton()}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
buttons={buttons}
|
||||
showCloseButton={true}
|
||||
showCancelButton={false}
|
||||
title={localize('com_ui_share_link_to_chat')}
|
||||
className="max-w-[550px]"
|
||||
main={
|
||||
<>
|
||||
<ShareDialog
|
||||
setDialogOpen={setOpen}
|
||||
conversationId={conversationId}
|
||||
title={title}
|
||||
share={share}
|
||||
setShare={setShare}
|
||||
isUpdated={isUpdated}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
80
client/src/components/Conversations/ShareDialog.tsx
Normal file
80
client/src/components/Conversations/ShareDialog.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
|
||||
import { useCreateSharedLinkMutation } from '~/data-provider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TSharedLink } from 'librechat-data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
export default function ShareDialog({
|
||||
conversationId,
|
||||
title,
|
||||
share,
|
||||
setShare,
|
||||
setDialogOpen,
|
||||
isUpdated,
|
||||
}: {
|
||||
conversationId: string;
|
||||
title: string;
|
||||
share: TSharedLink | null;
|
||||
setShare: (share: TSharedLink | null) => void;
|
||||
setDialogOpen: (open: boolean) => void;
|
||||
isUpdated: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { mutate, isLoading } = useCreateSharedLinkMutation();
|
||||
const [isNewSharedLink, setIsNewSharedLink] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || share) {
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
conversationId,
|
||||
title,
|
||||
isAnonymous: true,
|
||||
};
|
||||
|
||||
mutate(data, {
|
||||
onSuccess: (result) => {
|
||||
setShare(result);
|
||||
setIsNewSharedLink(!result.isPublic);
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
setDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// mutation.mutate should only be called once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="h-full py-2 text-gray-400 dark:text-gray-200">
|
||||
{(() => {
|
||||
if (isLoading) {
|
||||
return <Spinner className="m-auto h-14 animate-spin" />;
|
||||
}
|
||||
|
||||
if (isUpdated) {
|
||||
return isNewSharedLink
|
||||
? localize('com_ui_share_created_message')
|
||||
: localize('com_ui_share_updated_message');
|
||||
}
|
||||
|
||||
return share?.isPublic
|
||||
? localize('com_ui_share_update_message')
|
||||
: localize('com_ui_share_create_message');
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
client/src/components/Conversations/SharedLinkButton.tsx
Normal file
115
client/src/components/Conversations/SharedLinkButton.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Copy, Link } from 'lucide-react';
|
||||
import { useUpdateSharedLinkMutation } from '~/data-provider';
|
||||
import type { TSharedLink } from 'librechat-data-provider';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function SharedLinkButton({
|
||||
conversationId,
|
||||
share,
|
||||
setShare,
|
||||
isUpdated,
|
||||
setIsUpdated,
|
||||
}: {
|
||||
conversationId: string;
|
||||
share: TSharedLink;
|
||||
setShare: (share: TSharedLink) => void;
|
||||
isUpdated: boolean;
|
||||
setIsUpdated: (isUpdated: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation();
|
||||
|
||||
const copyLink = () => {
|
||||
if (!share) {
|
||||
return;
|
||||
}
|
||||
setIsCopying(true);
|
||||
const sharedLink =
|
||||
window.location.protocol + '//' + window.location.host + '/share/' + share.shareId;
|
||||
copy(sharedLink);
|
||||
setTimeout(() => {
|
||||
setIsCopying(false);
|
||||
}, 1500);
|
||||
};
|
||||
const updateSharedLink = async () => {
|
||||
if (!share) {
|
||||
return;
|
||||
}
|
||||
const result = await mutateAsync({
|
||||
shareId: share.shareId,
|
||||
conversationId: conversationId,
|
||||
isPublic: true,
|
||||
isVisible: true,
|
||||
isAnonymous: true,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
setShare(result);
|
||||
setIsUpdated(true);
|
||||
copyLink();
|
||||
}
|
||||
};
|
||||
const getHandler = () => {
|
||||
if (isUpdated) {
|
||||
return {
|
||||
handler: () => {
|
||||
copyLink();
|
||||
},
|
||||
label: (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{localize('com_ui_copy_link')}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (share?.isPublic) {
|
||||
return {
|
||||
handler: async () => {
|
||||
await updateSharedLink();
|
||||
},
|
||||
|
||||
label: (
|
||||
<>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
{localize('com_ui_update_link')}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
handler: updateSharedLink,
|
||||
label: (
|
||||
<>
|
||||
<Link className="mr-2 h-4 w-4" />
|
||||
{localize('com_ui_create_link')}
|
||||
</>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const handlers = getHandler();
|
||||
return (
|
||||
<Button
|
||||
disabled={isLoading || isCopying}
|
||||
onClick={() => {
|
||||
handlers.handler();
|
||||
}}
|
||||
className="min-w-32 whitespace-nowrap bg-green-500 text-white hover:bg-green-600 dark:bg-green-600 dark:text-white dark:hover:bg-green-800"
|
||||
>
|
||||
{isCopying && (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{localize('com_ui_copied')}
|
||||
</>
|
||||
)}
|
||||
{!isCopying && !isLoading && handlers.label}
|
||||
{!isCopying && isLoading && <Spinner className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { createContext, useContext } from 'react';
|
|||
const defaultFunction: (value: boolean) => void = () => ({});
|
||||
export const ToggleContext = createContext({
|
||||
setPopoverActive: defaultFunction,
|
||||
isPopoverActive: false,
|
||||
});
|
||||
|
||||
export const useToggle = () => useContext(ToggleContext);
|
||||
|
|
|
|||
|
|
@ -1,36 +1,14 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
|
||||
import {
|
||||
Plugin,
|
||||
GPTIcon,
|
||||
UserIcon,
|
||||
PaLMIcon,
|
||||
CodeyIcon,
|
||||
GeminiIcon,
|
||||
AssistantIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '~/components/svg';
|
||||
import { UserIcon } from '~/components/svg';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import useAvatar from '~/hooks/Messages/useAvatar';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { IconProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
import MessageEndpointIcon from './MessageEndpointIcon';
|
||||
|
||||
const Icon: React.FC<IconProps> = (props) => {
|
||||
const { user } = useAuthContext();
|
||||
const {
|
||||
error,
|
||||
button,
|
||||
iconURL,
|
||||
endpoint,
|
||||
jailbreak,
|
||||
size = 30,
|
||||
model = '',
|
||||
assistantName,
|
||||
isCreatedByUser,
|
||||
} = props;
|
||||
const { size = 30, isCreatedByUser } = props;
|
||||
|
||||
const avatarSrc = useAvatar(user);
|
||||
const localize = useLocalize();
|
||||
|
|
@ -65,141 +43,7 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const endpointIcons = {
|
||||
[EModelEndpoint.assistants]: {
|
||||
icon: props.iconURL ? (
|
||||
<div className="relative flex h-6 w-6 items-center justify-center">
|
||||
<div
|
||||
title={assistantName}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn('overflow-hidden rounded-full', props.className ?? '')}
|
||||
>
|
||||
<img
|
||||
className="shadow-stroke h-full w-full object-cover"
|
||||
src={props.iconURL}
|
||||
alt={assistantName}
|
||||
style={{ height: '80', width: '80' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 w-6">
|
||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<AssistantIcon className="h-2/3 w-2/3 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
name: endpoint,
|
||||
},
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon size={size * 0.5555555555555556} />,
|
||||
bg: 'linear-gradient(0.375turn, #61bde2, #4389d0)',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg:
|
||||
typeof model === 'string' && model.toLowerCase().includes('gpt-4') ? '#AB68FF' : '#19C37D',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.gptPlugins]: {
|
||||
icon: <Plugin size={size * 0.7} />,
|
||||
bg: `rgba(69, 89, 164, ${button ? 0.75 : 1})`,
|
||||
name: 'Plugins',
|
||||
},
|
||||
[EModelEndpoint.google]: {
|
||||
icon: model?.toLowerCase()?.includes('code') ? (
|
||||
<CodeyIcon size={size * 0.75} />
|
||||
) : model?.toLowerCase()?.includes('gemini') ? (
|
||||
<GeminiIcon size={size * 0.7} />
|
||||
) : (
|
||||
<PaLMIcon size={size * 0.7} />
|
||||
),
|
||||
name: model?.toLowerCase()?.includes('code')
|
||||
? 'Codey'
|
||||
: model?.toLowerCase()?.includes('gemini')
|
||||
? 'Gemini'
|
||||
: 'PaLM2',
|
||||
},
|
||||
[EModelEndpoint.anthropic]: {
|
||||
icon: <AnthropicIcon size={size * 0.5555555555555556} />,
|
||||
bg: '#d09a74',
|
||||
name: 'Claude',
|
||||
},
|
||||
[EModelEndpoint.bingAI]: {
|
||||
icon: jailbreak ? (
|
||||
<img src="/assets/bingai-jb.png" alt="Bing Icon" />
|
||||
) : (
|
||||
<img src="/assets/bingai.png" alt="Sydney Icon" />
|
||||
),
|
||||
name: jailbreak ? 'Sydney' : 'BingAI',
|
||||
},
|
||||
[EModelEndpoint.chatGPTBrowser]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg:
|
||||
typeof model === 'string' && model.toLowerCase().includes('gpt-4')
|
||||
? '#AB68FF'
|
||||
: `rgba(0, 163, 255, ${button ? 0.75 : 1})`,
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.custom]: {
|
||||
icon: <CustomMinimalIcon size={size * 0.7} />,
|
||||
name: 'Custom',
|
||||
},
|
||||
null: { icon: <GPTIcon size={size * 0.7} />, bg: 'grey', name: 'N/A' },
|
||||
default: {
|
||||
icon: (
|
||||
<div className="h-6 w-6">
|
||||
<div className="overflow-hidden rounded-full">
|
||||
<UnknownIcon
|
||||
iconURL={props.iconURL}
|
||||
endpoint={endpoint ?? ''}
|
||||
className="h-full w-full object-contain"
|
||||
context="message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
name: endpoint,
|
||||
},
|
||||
};
|
||||
|
||||
let { icon, bg, name } =
|
||||
endpoint && endpointIcons[endpoint] ? endpointIcons[endpoint] : endpointIcons.default;
|
||||
|
||||
if (iconURL && endpointIcons[iconURL]) {
|
||||
({ icon, bg, name } = endpointIcons[iconURL]);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={name}
|
||||
style={{
|
||||
background: bg || 'transparent',
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white',
|
||||
props.className || '',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{error && (
|
||||
<span className="absolute right-0 top-[20px] -mr-2 flex h-3 w-3 items-center justify-center rounded-full border border-white bg-red-500 text-[10px] text-white">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <MessageEndpointIcon {...props} />;
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
|
|
|
|||
166
client/src/components/Endpoints/MessageEndpointIcon.tsx
Normal file
166
client/src/components/Endpoints/MessageEndpointIcon.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
|
||||
import {
|
||||
Plugin,
|
||||
GPTIcon,
|
||||
PaLMIcon,
|
||||
CodeyIcon,
|
||||
GeminiIcon,
|
||||
AssistantIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '~/components/svg';
|
||||
|
||||
import { IconProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
||||
const {
|
||||
error,
|
||||
button,
|
||||
iconURL,
|
||||
endpoint,
|
||||
jailbreak,
|
||||
size = 30,
|
||||
model = '',
|
||||
assistantName,
|
||||
} = props;
|
||||
|
||||
const endpointIcons = {
|
||||
[EModelEndpoint.assistants]: {
|
||||
icon: props.iconURL ? (
|
||||
<div className="relative flex h-6 w-6 items-center justify-center">
|
||||
<div
|
||||
title={assistantName}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn('overflow-hidden rounded-full', props.className ?? '')}
|
||||
>
|
||||
<img
|
||||
className="shadow-stroke h-full w-full object-cover"
|
||||
src={props.iconURL}
|
||||
alt={assistantName}
|
||||
style={{ height: '80', width: '80' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-6 w-6">
|
||||
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<AssistantIcon className="h-2/3 w-2/3 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
name: endpoint,
|
||||
},
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon size={size * 0.5555555555555556} />,
|
||||
bg: 'linear-gradient(0.375turn, #61bde2, #4389d0)',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg:
|
||||
typeof model === 'string' && model.toLowerCase().includes('gpt-4') ? '#AB68FF' : '#19C37D',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.gptPlugins]: {
|
||||
icon: <Plugin size={size * 0.7} />,
|
||||
bg: `rgba(69, 89, 164, ${button ? 0.75 : 1})`,
|
||||
name: 'Plugins',
|
||||
},
|
||||
[EModelEndpoint.google]: {
|
||||
icon: model?.toLowerCase()?.includes('code') ? (
|
||||
<CodeyIcon size={size * 0.75} />
|
||||
) : model?.toLowerCase()?.includes('gemini') ? (
|
||||
<GeminiIcon size={size * 0.7} />
|
||||
) : (
|
||||
<PaLMIcon size={size * 0.7} />
|
||||
),
|
||||
name: model?.toLowerCase()?.includes('code')
|
||||
? 'Codey'
|
||||
: model?.toLowerCase()?.includes('gemini')
|
||||
? 'Gemini'
|
||||
: 'PaLM2',
|
||||
},
|
||||
[EModelEndpoint.anthropic]: {
|
||||
icon: <AnthropicIcon size={size * 0.5555555555555556} />,
|
||||
bg: '#d09a74',
|
||||
name: 'Claude',
|
||||
},
|
||||
[EModelEndpoint.bingAI]: {
|
||||
icon: jailbreak ? (
|
||||
<img src="/assets/bingai-jb.png" alt="Bing Icon" />
|
||||
) : (
|
||||
<img src="/assets/bingai.png" alt="Sydney Icon" />
|
||||
),
|
||||
name: jailbreak ? 'Sydney' : 'BingAI',
|
||||
},
|
||||
[EModelEndpoint.chatGPTBrowser]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg:
|
||||
typeof model === 'string' && model.toLowerCase().includes('gpt-4')
|
||||
? '#AB68FF'
|
||||
: `rgba(0, 163, 255, ${button ? 0.75 : 1})`,
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.custom]: {
|
||||
icon: <CustomMinimalIcon size={size * 0.7} />,
|
||||
name: 'Custom',
|
||||
},
|
||||
null: { icon: <GPTIcon size={size * 0.7} />, bg: 'grey', name: 'N/A' },
|
||||
default: {
|
||||
icon: (
|
||||
<div className="h-6 w-6">
|
||||
<div className="overflow-hidden rounded-full">
|
||||
<UnknownIcon
|
||||
iconURL={props.iconURL}
|
||||
endpoint={endpoint ?? ''}
|
||||
className="h-full w-full object-contain"
|
||||
context="message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
name: endpoint,
|
||||
},
|
||||
};
|
||||
|
||||
let { icon, bg, name } =
|
||||
endpoint && endpointIcons[endpoint] ? endpointIcons[endpoint] : endpointIcons.default;
|
||||
|
||||
if (iconURL && endpointIcons[iconURL]) {
|
||||
({ icon, bg, name } = endpointIcons[iconURL]);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={name}
|
||||
style={{
|
||||
background: bg || 'transparent',
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white',
|
||||
props.className || '',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{error && (
|
||||
<span className="absolute right-0 top-[20px] -mr-2 flex h-3 w-3 items-center justify-center rounded-full border border-white bg-red-500 text-[10px] text-white">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageEndpointIcon;
|
||||
|
|
@ -3,6 +3,7 @@ import { InfoIcon } from 'lucide-react';
|
|||
import React, { useRef, useState, RefObject } from 'react';
|
||||
import Clipboard from '~/components/svg/Clipboard';
|
||||
import CheckMark from '~/components/svg/CheckMark';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
type CodeBarProps = {
|
||||
|
|
@ -18,6 +19,7 @@ type CodeBlockProps = Pick<CodeBarProps, 'lang' | 'plugin' | 'error'> & {
|
|||
};
|
||||
|
||||
const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plugin = null }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
return (
|
||||
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700">
|
||||
|
|
@ -41,13 +43,13 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plug
|
|||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark />
|
||||
{error ? '' : 'Copied!'}
|
||||
<CheckMark className="h-[18px] w-[18px]" />
|
||||
{error ? '' : localize('com_ui_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
{error ? '' : 'Copy code'}
|
||||
{error ? '' : localize('com_ui_copy_code')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, memo, ReactNode } from 'react';
|
|||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TResPlugin, TInput } from 'librechat-data-provider';
|
||||
import { ChevronDownIcon, LucideProps } from 'lucide-react';
|
||||
import { useShareContext } from '~/Providers';
|
||||
import { cn, formatJSON } from '~/utils';
|
||||
import { Spinner } from '~/components';
|
||||
import CodeBlock from './CodeBlock';
|
||||
|
|
@ -31,7 +32,9 @@ type PluginProps = {
|
|||
};
|
||||
|
||||
const Plugin: React.FC<PluginProps> = ({ plugin }) => {
|
||||
const { isSharedConvo } = useShareContext();
|
||||
const { data: plugins = {} } = useGetEndpointsQuery({
|
||||
enabled: !isSharedConvo,
|
||||
select: (data) => data?.gptPlugins?.plugins,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import NavToggle from './NavToggle';
|
|||
import NavLinks from './NavLinks';
|
||||
import NewChat from './NewChat';
|
||||
import { cn } from '~/utils';
|
||||
import { ConversationListResponse } from 'librechat-data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
const Nav = ({ navVisible, setNavVisible }) => {
|
||||
|
|
@ -59,7 +60,7 @@ const Nav = ({ navVisible, setNavVisible }) => {
|
|||
{ enabled: isAuthenticated },
|
||||
);
|
||||
|
||||
const { containerRef, moveToTop } = useNavScrolling({
|
||||
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
|
||||
setShowLoading,
|
||||
hasNextPage: searchQuery ? searchQueryRes.hasNextPage : hasNextPage,
|
||||
fetchNextPage: searchQuery ? searchQueryRes.fetchNextPage : fetchNextPage,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useConversation, useConversations, useOnClickOutside } from '~/hooks';
|
|||
import ImportConversations from './ImportConversations';
|
||||
import { ClearChatsButton } from './ClearChats';
|
||||
import DangerButton from '../DangerButton';
|
||||
import SharedLinks from './SharedLinks';
|
||||
|
||||
export const RevokeKeysButton = ({
|
||||
showText = true,
|
||||
|
|
@ -107,6 +108,9 @@ function Data() {
|
|||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<ImportConversations />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<SharedLinks />
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
|
||||
<RevokeKeysButton all={true} />
|
||||
</div>
|
||||
|
|
|
|||
178
client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx
Normal file
178
client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
|
||||
import { MessageSquare, Link as LinkIcon } from 'lucide-react';
|
||||
import { useMemo, useState, MouseEvent } from 'react';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
|
||||
|
||||
import { cn } from '~/utils';
|
||||
import {
|
||||
Spinner,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TrashIcon,
|
||||
} from '~/components';
|
||||
import { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function SharedLinkDeleteButton({
|
||||
shareId,
|
||||
setIsDeleting,
|
||||
}: {
|
||||
shareId: string;
|
||||
setIsDeleting: (isDeleting: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const mutation = useDeleteSharedLinkMutation();
|
||||
|
||||
const handleDelete = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (mutation.isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
await mutation.mutateAsync({ shareId });
|
||||
setIsDeleting(false);
|
||||
};
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span onClick={handleDelete}>
|
||||
<TrashIcon />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={0}>
|
||||
{localize('com_ui_delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
function SourceChatButton({ conversationId }: { conversationId: string }) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link to={`/c/${conversationId}`} target="_blank" rel="noreferrer">
|
||||
<MessageSquare className="h-4 w-4 hover:text-gray-300" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={0}>
|
||||
{localize('com_nav_source_chat')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={sharedLink.conversationId}
|
||||
className="border-b border-gray-200 text-sm font-normal dark:border-white/10"
|
||||
>
|
||||
<td
|
||||
className={cn(
|
||||
'flex items-center py-3 text-blue-800/70 dark:text-blue-500',
|
||||
isDeleting && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<Link to={`/share/${sharedLink.shareId}`} target="_blank" rel="noreferrer" className="flex">
|
||||
<LinkIcon className="mr-1 h-5 w-5" />
|
||||
{sharedLink.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex justify-between">
|
||||
<div className={cn('flex justify-start dark:text-gray-200', isDeleting && 'opacity-50')}>
|
||||
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 text-gray-400',
|
||||
isDeleting && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{sharedLink.conversationId && (
|
||||
<>
|
||||
<SourceChatButton conversationId={sharedLink.conversationId} />
|
||||
<div className={cn('h-4 w-4 cursor-pointer', !isDeleting && 'hover:text-gray-300')}>
|
||||
<SharedLinkDeleteButton
|
||||
shareId={sharedLink.shareId}
|
||||
setIsDeleting={setIsDeleting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
export default function ShareLinkTable({ className }: { className?: string }) {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSharedLinksInfiniteQuery(
|
||||
{ pageNumber: '1', isPublic: true },
|
||||
{ enabled: isAuthenticated },
|
||||
);
|
||||
|
||||
const { containerRef } = useNavScrolling<SharedLinksResponse>({
|
||||
setShowLoading,
|
||||
hasNextPage: hasNextPage,
|
||||
fetchNextPage: fetchNextPage,
|
||||
isFetchingNextPage: isFetchingNextPage,
|
||||
});
|
||||
|
||||
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
|
||||
const classProp: { className?: string } = {
|
||||
className: 'p-1 hover:text-black dark:hover:text-white',
|
||||
};
|
||||
if (className) {
|
||||
classProp.className = className;
|
||||
}
|
||||
|
||||
if (!sharedLinks || sharedLinks.length === 0) {
|
||||
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full gap-2',
|
||||
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
'max-h-[350px]',
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<table className="table-fixed text-left">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-700">
|
||||
<tr className="border-b border-gray-200 text-sm font-semibold text-gray-500 dark:border-white/10 dark:text-gray-200">
|
||||
<th className="p-3">{localize('com_nav_shared_links_name')}</th>
|
||||
<th className="p-3">{localize('com_nav_shared_links_date_shared')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sharedLinks.map((sharedLink) => (
|
||||
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(isFetchingNextPage || showLoading) && (
|
||||
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx
Normal file
29
client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { Dialog, DialogTrigger } from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
|
||||
import ShareLinkTable from './SharedLinkTable';
|
||||
|
||||
export default function SharedLinks() {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div> {localize('com_nav_shared_links')} </div>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="btn btn-neutral relative ">
|
||||
{localize('com_nav_shared_links_manage')}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title={localize('com_nav_shared_links')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ShareLinkTable />}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import ArchiveButton from '~/components/Conversations/ArchiveButton';
|
|||
import DeleteButton from '~/components/Conversations/DeleteButton';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { cn } from '~/utils';
|
||||
import { ConversationListResponse } from 'librechat-data-provider';
|
||||
|
||||
export default function ArchivedChatsTable({ className }: { className?: string }) {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -17,7 +18,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
|
|||
{ enabled: isAuthenticated },
|
||||
);
|
||||
|
||||
const { containerRef, moveToTop } = useNavScrolling({
|
||||
const { containerRef, moveToTop } = useNavScrolling<ConversationListResponse>({
|
||||
setShowLoading,
|
||||
hasNextPage: hasNextPage,
|
||||
fetchNextPage: fetchNextPage,
|
||||
|
|
|
|||
100
client/src/components/Share/Message.tsx
Normal file
100
client/src/components/Share/Message.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type { TMessageProps } from '~/common';
|
||||
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import { Plugin } from '~/components/Messages/Content';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
import Icon from './MessageIcon';
|
||||
export default function Message(props: TMessageProps) {
|
||||
const {
|
||||
message,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
conversation,
|
||||
setSiblingIdx,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
} = props;
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
|
||||
|
||||
let messageLabel = '';
|
||||
if (isCreatedByUser) {
|
||||
messageLabel = 'anonymous';
|
||||
} else {
|
||||
messageLabel = message.sender;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent">
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<Icon message={message} conversation={conversation} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className="select-none font-semibold">{messageLabel}</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
{/* Legacy Plugins */}
|
||||
{message?.plugin && <Plugin plugin={message?.plugin} />}
|
||||
{message?.content ? (
|
||||
<SearchContent message={message} />
|
||||
) : (
|
||||
<MessageContent
|
||||
edit={false}
|
||||
error={error}
|
||||
isLast={false}
|
||||
ask={() => ({})}
|
||||
text={text ?? ''}
|
||||
message={message}
|
||||
isSubmitting={false}
|
||||
enterEdit={() => ({})}
|
||||
unfinished={!!unfinished}
|
||||
isCreatedByUser={isCreatedByUser ?? true}
|
||||
siblingIdx={siblingIdx ?? 0}
|
||||
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SubRow classes="text-xs">
|
||||
<SiblingSwitch
|
||||
siblingIdx={siblingIdx}
|
||||
siblingCount={siblingCount}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
/>
|
||||
<MinimalHoverButtons message={message} />
|
||||
</SubRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiMessage
|
||||
key={messageId}
|
||||
messageId={messageId}
|
||||
messagesTree={children ?? []}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
client/src/components/Share/MessageIcon.tsx
Normal file
71
client/src/components/Share/MessageIcon.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { TMessage, TPreset, Assistant } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import { getIconEndpoint } from '~/utils';
|
||||
import { UserIcon } from '../svg';
|
||||
|
||||
export default function MessageIcon(
|
||||
props: Pick<TMessageProps, 'message' | 'conversation'> & {
|
||||
assistant?: false | Assistant;
|
||||
},
|
||||
) {
|
||||
const { message, conversation, assistant } = props;
|
||||
|
||||
const assistantName = assistant ? (assistant.name as string | undefined) : '';
|
||||
const assistantAvatar = assistant ? (assistant.metadata?.avatar as string | undefined) : '';
|
||||
|
||||
const messageSettings = useMemo(
|
||||
() => ({
|
||||
...(conversation ?? {}),
|
||||
...({
|
||||
...message,
|
||||
iconURL: message?.iconURL ?? '',
|
||||
} as TMessage),
|
||||
}),
|
||||
[conversation, message],
|
||||
);
|
||||
|
||||
const iconURL = messageSettings?.iconURL;
|
||||
let endpoint = messageSettings?.endpoint;
|
||||
endpoint = getIconEndpoint({ endpointsConfig: undefined, iconURL, endpoint });
|
||||
|
||||
if (!message?.isCreatedByUser && iconURL && iconURL.includes('http')) {
|
||||
return (
|
||||
<ConvoIconURL
|
||||
preset={messageSettings as typeof messageSettings & TPreset}
|
||||
context="message"
|
||||
assistantAvatar={assistantAvatar}
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (message?.isCreatedByUser) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageEndpointIcon
|
||||
{...messageSettings}
|
||||
endpoint={endpoint}
|
||||
iconURL={!assistant ? undefined : assistantAvatar}
|
||||
model={message?.model ?? conversation?.model}
|
||||
assistantName={assistantName}
|
||||
size={28.8}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
client/src/components/Share/MessagesView.tsx
Normal file
47
client/src/components/Share/MessagesView.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { useState } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MultiMessage from './MultiMessage';
|
||||
|
||||
export default function MessagesView({
|
||||
messagesTree: _messagesTree,
|
||||
conversationId,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
conversationId: string;
|
||||
}) {
|
||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||
return (
|
||||
<div className="flex-1 pb-[50px]">
|
||||
<div className="dark:gpt-dark-gray relative h-full">
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
|
||||
{(_messagesTree && _messagesTree?.length == 0) || _messagesTree === null ? (
|
||||
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
|
||||
Nothing found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messagesTree={_messagesTree}
|
||||
messageId={conversationId ?? null}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="dark:gpt-dark-gray group h-0 w-full flex-shrink-0 dark:border-gray-800/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
client/src/components/Share/MultiMessage.tsx
Normal file
54
client/src/components/Share/MultiMessage.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import Message from './Message';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MultiMessage({
|
||||
// messageId is used recursively here
|
||||
messageId,
|
||||
messagesTree,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
}: TMessageProps) {
|
||||
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
|
||||
|
||||
const setSiblingIdxRev = (value: number) => {
|
||||
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
|
||||
setSiblingIdx(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messagesTree?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesTree?.length && siblingIdx >= messagesTree?.length) {
|
||||
setSiblingIdx(0);
|
||||
}
|
||||
}, [siblingIdx, messagesTree?.length, setSiblingIdx]);
|
||||
|
||||
if (!(messagesTree && messagesTree?.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = messagesTree[messagesTree.length - siblingIdx - 1];
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={message.messageId}
|
||||
message={message}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
siblingIdx={messagesTree.length - siblingIdx - 1}
|
||||
siblingCount={messagesTree.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
client/src/components/Share/ShareView.tsx
Normal file
60
client/src/components/Share/ShareView.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { memo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetSharedMessages } from 'librechat-data-provider/react-query';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import MessagesView from './MessagesView';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { buildTree } from '~/utils';
|
||||
import Footer from '../Chat/Footer';
|
||||
|
||||
function SharedView() {
|
||||
const localize = useLocalize();
|
||||
const { shareId } = useParams();
|
||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
const messagesTree = dataTree?.length === 0 ? null : dataTree ?? null;
|
||||
|
||||
return (
|
||||
<ShareContext.Provider value={{ isSharedConvo: true }}>
|
||||
<div
|
||||
className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800"
|
||||
style={{ paddingBottom: '50px' }}
|
||||
>
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="" />
|
||||
</div>
|
||||
) : data && messagesTree && messagesTree.length !== 0 ? (
|
||||
<>
|
||||
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<h1 className="text-4xl font-bold dark:text-white">{data.title}</h1>
|
||||
<div className="border-b pb-6 text-base text-gray-300">
|
||||
{new Date(data.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
{localize('com_ui_shared_link_not_found')}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
||||
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-gray-50 to-transparent px-2 pb-2 pt-8 text-xs text-gray-600 dark:from-gray-800 dark:text-gray-300 md:px-[60px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SharedView);
|
||||
Loading…
Add table
Add a link
Reference in a new issue