Merge branch 'main' into re-add-download-audio

This commit is contained in:
Marco Beretta 2024-07-15 18:37:04 +02:00
commit 32d84c85ea
No known key found for this signature in database
GPG key ID: 487C4BA02481F779
408 changed files with 16931 additions and 4946 deletions

View file

@ -1,8 +1,8 @@
import { ThemeSelector } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { BlinkAnimation } from './BlinkAnimation';
import { TStartupConfig } from 'librechat-data-provider';
import SocialLoginRender from './SocialLoginRender';
import { ThemeSelector } from '~/components/ui';
import Footer from './Footer';
const ErrorRender = ({ children }: { children: React.ReactNode }) => (

View file

@ -0,0 +1,47 @@
import { PlusCircle } from 'lucide-react';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { mainTextareaId } from '~/common';
import { cn } from '~/utils';
function AddMultiConvo({ className = '' }: { className?: string }) {
const { conversation } = useChatContext();
const { setConversation: setAddedConvo } = useAddedChatContext();
const clickHandler = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
setAddedConvo({
...convo,
title: '',
});
const textarea = document.getElementById(mainTextareaId);
if (textarea) {
textarea.focus();
}
};
if (!conversation) {
return null;
}
if (isAssistantsEndpoint(conversation.endpoint)) {
return null;
}
return (
<button
onClick={clickHandler}
className={cn(
'group m-1.5 flex w-fit cursor-pointer items-center rounded text-sm hover:bg-border-medium focus-visible:bg-border-medium focus-visible:outline-0',
className,
)}
>
<PlusCircle size={16} />
</button>
);
}
export default AddMultiConvo;

View file

@ -1,10 +1,12 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import { ChatContext, useFileMapContext } from '~/Providers';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import MessagesView from './Messages/MessagesView';
import { useChatHelpers, useSSE } from '~/hooks';
import { Spinner } from '~/components/svg';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@ -16,8 +18,8 @@ import store from '~/store';
function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const submissionAtIndex = useRecoilValue(store.submissionByIndex(0));
useSSE(submissionAtIndex);
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
const fileMap = useFileMapContext();
@ -30,25 +32,37 @@ function ChatView({ index = 0 }: { index?: number }) {
});
const chatHelpers = useChatHelpers(index, conversationId);
const addedChatHelpers = useAddedResponse({ rootIndex: index });
useSSE(rootSubmission, chatHelpers, false);
useSSE(addedSubmission, addedChatHelpers, true);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
return (
<ChatContext.Provider value={chatHelpers}>
<Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<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">
<ChatForm index={index} />
<Footer />
</div>
</Presentation>
</ChatContext.Provider>
<ChatFormProvider {...methods}>
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation useSidePanel={true}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
<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">
<ChatForm index={index} />
<Footer />
</div>
</Presentation>
</AddedChatContext.Provider>
</ChatContext.Provider>
</ChatFormProvider>
);
}

View file

@ -47,7 +47,7 @@ export default function Footer({ className }: { className?: string }) {
: '[LibreChat ' +
Constants.VERSION +
'](https://librechat.ai) - ' +
localize('com_ui_pay_per_call')
localize('com_ui_latest_footer')
).split('|');
const mainContentRender = mainContentParts.map((text, index) => (

View file

@ -6,6 +6,7 @@ import type { ContextType } from '~/common';
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import ExportAndShareMenu from './ExportAndShareMenu';
import HeaderOptions from './Input/HeaderOptions';
import AddMultiConvo from './AddMultiConvo';
import { useMediaQuery } from '~/hooks';
const defaultInterface = getConfigDefaults().interface;
@ -36,6 +37,7 @@ export default function Header() {
className="pl-0"
/>
)}
<AddMultiConvo />
</div>
{!isSmallScreen && (
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />

View file

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TConversation, TEndpointOption, TPreset } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import useGetSender from '~/hooks/Conversations/useGetSender';
import { EndpointIcon } from '~/components/Endpoints';
import { getPresetTitle } from '~/utils';
export default function AddedConvo({
addedConvo,
setAddedConvo,
}: {
addedConvo: TConversation | null;
setAddedConvo: SetterOrUpdater<TConversation | null>;
}) {
const getSender = useGetSender();
const { data: endpointsConfig } = useGetEndpointsQuery();
const title = useMemo(() => {
const sender = getSender(addedConvo as TEndpointOption);
const title = getPresetTitle(addedConvo as TPreset);
return `+ ${sender}: ${title}`;
}, [addedConvo, getSender]);
if (!addedConvo) {
return null;
}
return (
<div className="flex items-start gap-4 py-2.5 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex h-6 w-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<EndpointIcon
conversation={addedConvo}
endpointsConfig={endpointsConfig}
containerClassName="shadow-stroke overflow-hidden rounded-full"
context="menu-item"
size={20}
/>
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{title}
</span>
<button
className="text-token-text-secondary flex-shrink-0"
type="button"
onClick={() => setAddedConvo(null)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
className="icon-lg"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M7.293 7.293a1 1 0 0 1 1.414 0L12 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414L13.414 12l3.293 3.293a1 1 0 0 1-1.414 1.414L12 13.414l-3.293 3.293a1 1 0 0 1-1.414-1.414L10.586 12 7.293 8.707a1 1 0 0 1 0-1.414"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
);
}

View file

@ -1,8 +1,8 @@
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { ListeningIcon, Spinner } from '~/components/svg';
import { useLocalize, useSpeechToText } from '~/hooks';
import { useChatFormContext } from '~/Providers';
import { globalAudioId } from '~/common';
export default function AudioRecorder({
@ -12,7 +12,7 @@ export default function AudioRecorder({
disabled,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
methods: UseFormReturn<{ text: string }>;
methods: ReturnType<typeof useChatFormContext>;
ask: (data: { text: string }) => void;
disabled: boolean;
}) {
@ -31,15 +31,26 @@ export default function AudioRecorder({
}
};
const { isListening, isLoading, startRecording, stopRecording, speechText, clearText } =
useSpeechToText(handleTranscriptionComplete);
const {
isListening,
isLoading,
startRecording,
stopRecording,
interimTranscript,
speechText,
clearText,
} = useSpeechToText(handleTranscriptionComplete);
useEffect(() => {
if (textAreaRef.current) {
if (isListening && textAreaRef.current) {
methods.setValue('text', interimTranscript, {
shouldValidate: true,
});
} else if (textAreaRef.current) {
textAreaRef.current.value = speechText;
methods.setValue('text', speechText, { shouldValidate: true });
}
}, [speechText, methods, textAreaRef]);
}, [interimTranscript, speechText, methods, textAreaRef]);
const handleStartRecording = async () => {
await startRecording();

View file

@ -1,18 +1,29 @@
import { useForm } from 'react-hook-form';
import { memo, useRef, useMemo } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { memo, useCallback, useRef, useMemo, useState, useEffect } from 'react';
import {
supportsFiles,
mergeFileConfig,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { useAutoSave } from '~/hooks/Input/useAutoSave';
import { useRequiresKey, useTextarea } from '~/hooks';
import {
useChatContext,
useAddedChatContext,
useAssistantsMapContext,
useChatFormContext,
} from '~/Providers';
import {
useTextarea,
useAutoSave,
useRequiresKey,
useHandleKeyUp,
useSubmitMessage,
} from '~/hooks';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AttachFile from './Files/AttachFile';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
@ -26,58 +37,59 @@ import store from '~/store';
const ChatForm = ({ index = 0 }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const SpeechToText = useRecoilValue(store.SpeechToText);
const TextToSpeech = useRecoilValue(store.TextToSpeech);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const { requiresKey } = useRequiresKey();
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
const { requiresKey } = useRequiresKey();
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
});
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef,
submitButtonRef,
disabled: !!requiresKey,
});
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
useTextarea({
textAreaRef,
submitButtonRef,
disabled: !!requiresKey,
});
const {
ask,
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
setFilesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const methods = useChatFormContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
const { clearDraft } = useAutoSave({
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
textAreaRef,
setValue: methods.setValue,
files,
setFiles,
});
const assistantMap = useAssistantsMapContext();
const submitMessage = useCallback(
(data?: { text: string }) => {
if (!data) {
return console.warn('No data provided to submitMessage');
}
ask({ text: data.text });
methods.reset();
clearDraft();
},
[ask, methods, clearDraft],
);
const { submitMessage, submitPrompt } = useSubmitMessage({ clearDraft });
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;
@ -113,10 +125,26 @@ const ChatForm = ({ index = 0 }) => {
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
{showMentionPopover && (
<Mention setShowMentionPopover={setShowMentionPopover} textAreaRef={textAreaRef} />
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add"
includeAssistants={false}
/>
)}
{showMentionPopover && (
<Mention
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileRow
files={files}
setFiles={setFiles}
@ -162,7 +190,7 @@ const ChatForm = ({ index = 0 }) => {
endpointType={endpointType}
disabled={disableInputs}
/>
{isSubmitting && showStopButton ? (
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (

View file

@ -35,6 +35,7 @@ const AttachFile = ({
<button
disabled={!!disabled}
type="button"
tabIndex={1}
className="btn relative p-0 text-black dark:text-white"
aria-label="Attach files"
style={{ padding: 0 }}

View file

@ -66,7 +66,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
<label
htmlFor={`file-upload-${id}`}
className={cn(
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
'mr-1 flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-normal transition-colors hover:bg-gray-100 hover:text-green-600 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500',
statusColor,
containerClassName,
)}

View file

@ -7,7 +7,7 @@ const sourceToEndpoint = {
[FileSources.azure]: EModelEndpoint.azureOpenAI,
};
const sourceToClassname = {
[FileSources.openai]: 'bg-black/65',
[FileSources.openai]: 'bg-white/75 dark:bg-black/65',
[FileSources.azure]: 'azure-bg-color opacity-85',
};

View file

@ -96,7 +96,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
deleteFiles({ files: filesToDelete as TFile[] });
setRowSelection({});
}}
className="ml-1 gap-2 dark:hover:bg-gray-750/25 sm:ml-0"
className="ml-1 gap-2 dark:hover:bg-gray-850/25 sm:ml-0"
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
>
{isDeleting ? (
@ -121,7 +121,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{/* Filter Menu */}
<DropdownMenuContent
align="end"
className="z-[1001] dark:border-gray-700 dark:bg-gray-750"
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
>
{table
.getAllColumns()

View file

@ -57,7 +57,7 @@ export function SortFilterHeader<TData, TValue>({
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[1001] dark:border-gray-700 dark:bg-gray-750"
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
>
<DropdownMenuItem
onClick={() => column.toggleSorting(false)}

View file

@ -1,24 +1,39 @@
import { useState, useRef, useEffect } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { MentionOption } from '~/common';
import type { MentionOption, ConvoGenerator } from '~/common';
import useSelectMention from '~/hooks/Input/useSelectMention';
import { useAssistantsMapContext } from '~/Providers';
import useMentions from '~/hooks/Input/useMentions';
import { useLocalize, useCombobox } from '~/hooks';
import { removeAtSymbolIfLast } from '~/utils';
import { removeCharIfLast } from '~/utils';
import MentionItem from './MentionItem';
export default function Mention({
setShowMentionPopover,
newConversation,
textAreaRef,
commandChar = '@',
placeholder = 'com_ui_mention',
includeAssistants = true,
}: {
setShowMentionPopover: SetterOrUpdater<boolean>;
newConversation: ConvoGenerator;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
commandChar?: string;
placeholder?: string;
includeAssistants?: boolean;
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const { options, modelsConfig, assistantListMap, onSelectMention } = useMentions({
const { options, presets, modelSpecs, modelsConfig, endpointsConfig, assistantListMap } =
useMentions({ assistantMap, includeAssistants });
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
assistantMap,
endpointsConfig,
newConversation,
});
const [activeIndex, setActiveIndex] = useState(0);
@ -43,7 +58,7 @@ export default function Mention({
onSelectMention(mention);
if (textAreaRef.current) {
removeAtSymbolIfLast(textAreaRef.current);
removeCharIfLast(textAreaRef.current, commandChar);
}
};
@ -80,6 +95,14 @@ export default function Mention({
}
}, [open, options]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
const currentActiveItem = document.getElementById(`mention-item-${activeIndex}`);
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
@ -91,7 +114,7 @@ export default function Mention({
<input
autoFocus
ref={inputRef}
placeholder={localize('com_ui_mention')}
placeholder={localize(placeholder)}
className="mb-1 w-full border-0 bg-white p-2 text-sm focus:outline-none dark:bg-gray-700 dark:text-gray-200"
autoComplete="off"
value={searchValue}

View file

@ -66,7 +66,7 @@ export default function OptionsPopover({
{presetsDisabled ? null : (
<Button
type="button"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500"
className="h-auto w-[150px] justify-start rounded-md border border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-white"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />

View file

@ -122,7 +122,7 @@ export default function PopoverButtons({
type="button"
className={cn(
button.buttonClass,
'border-2 border-gray-300/50 focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:focus:ring-green-500',
'border border-gray-300/50 focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:focus:ring-green-500',
'ml-1 h-full bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-white',
buttonClass ?? '',
)}
@ -141,7 +141,7 @@ export default function PopoverButtons({
type="button"
className={cn(
button.buttonClass,
'flex justify-center border-2 border-gray-300/50 focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:focus:ring-green-500',
'flex justify-center border border-gray-300/50 focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:focus:ring-green-500',
'h-full w-full bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-white',
buttonClass ?? '',
)}

View file

@ -0,0 +1,231 @@
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useGetAllPromptGroups } from '~/data-provider';
import { useLocalize, useCombobox } from '~/hooks';
import { Spinner } from '~/components/svg';
import MentionItem from './MentionItem';
import store from '~/store';
const commandChar = '/';
const PopoverContainer = memo(
({
index,
children,
isVariableDialogOpen,
variableGroup,
setVariableDialogOpen,
}: {
index: number;
children: React.ReactNode;
isVariableDialogOpen: boolean;
variableGroup: TPromptGroup | null;
setVariableDialogOpen: (isOpen: boolean) => void;
}) => {
const showPromptsPopover = useRecoilValue(store.showPromptsPopoverFamily(index));
return (
<>
{showPromptsPopover ? children : null}
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={variableGroup}
/>
</>
);
},
);
function PromptsCommand({
index,
textAreaRef,
submitPrompt,
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
submitPrompt: (textPrompt: string) => void;
}) {
const localize = useLocalize();
const { data, isLoading } = useGetAllPromptGroups(undefined, {
select: (data) => {
const mappedArray = data.map((group) => ({
id: group._id,
value: group.command ?? group.name,
label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${
group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? ''
}`,
icon: <CategoryIcon category={group.category ?? ''} />,
}));
const promptsMap = mapPromptGroups(data);
return {
promptsMap,
promptGroups: mappedArray,
};
},
});
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(null);
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const prompts = useMemo(() => data?.promptGroups ?? [], [data]);
const promptsMap = useMemo(() => data?.promptsMap ?? {}, [data]);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '',
options: prompts,
});
const handleSelect = useCallback(
(mention?: PromptOption, e?: React.KeyboardEvent<HTMLInputElement>) => {
if (!mention) {
return;
}
setSearchValue('');
setOpen(false);
setShowPromptsPopover(false);
if (textAreaRef.current) {
removeCharIfLast(textAreaRef.current, commandChar);
}
const isValidPrompt = mention && promptsMap && promptsMap[mention.id];
if (!isValidPrompt) {
return;
}
const group = promptsMap[mention.id];
const hasVariables = detectVariables(group?.productionPrompt?.prompt ?? '');
if (group && hasVariables) {
if (e && e.key === 'Tab') {
e.preventDefault();
}
setVariableGroup(group);
setVariableDialogOpen(true);
return;
} else if (group) {
submitPrompt(group.productionPrompt?.prompt ?? '');
}
},
[setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt],
);
useEffect(() => {
if (!open) {
setActiveIndex(0);
} else {
setVariableGroup(null);
}
}, [open]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
const currentActiveItem = document.getElementById(`prompt-item-${activeIndex}`);
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [activeIndex]);
return (
<PopoverContainer
index={index}
isVariableDialogOpen={isVariableDialogOpen}
variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen}
>
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}
className="mb-1 w-full border-0 bg-surface-tertiary-alt p-2 text-sm focus:outline-none dark:text-gray-200"
autoComplete="off"
value={searchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setShowPromptsPopover(false);
textAreaRef.current?.focus();
}
if (e.key === 'ArrowDown') {
setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (e.key === 'Enter') {
e.preventDefault();
}
handleSelect(matches[activeIndex] as PromptOption | undefined, e);
} else if (e.key === 'Backspace' && searchValue === '') {
setOpen(false);
setShowPromptsPopover(false);
textAreaRef.current?.focus();
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
setShowPromptsPopover(false);
}, 150);
}}
/>
<div className="max-h-40 overflow-y-auto">
{(() => {
if (isLoading && open) {
return (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
);
}
if (!isLoading && open) {
return (matches as PromptOption[]).map((mention, index) => (
<MentionItem
index={index}
key={`${mention.value}-${index}`}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
));
}
return null;
})()}
</div>
</div>
</div>
</PopoverContainer>
);
}
export default memo(PromptsCommand);

View file

@ -1,33 +1,29 @@
export default function StopButton({ stop, setShowStopButton }) {
return (
<div className="absolute bottom-0 right-2 top-0 p-1 md:right-3 md:p-2">
<div className="flex h-full">
<div className="flex h-full flex-row items-center justify-center gap-3">
<button
type="button"
className="border-gizmo-gray-900 rounded-full border-2 p-1 dark:border-gray-200"
aria-label="Stop generating"
onClick={(e) => {
setShowStopButton(false);
stop(e);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="text-gizmo-gray-900 h-2 w-2 dark:text-gray-200"
height="16"
width="16"
>
<path
d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2z"
strokeWidth="0"
></path>
</svg>
</button>
</div>
</div>
<div className="absolute bottom-3 right-2 md:bottom-4 md:right-4">
<button
type="button"
className="border-gizmo-gray-900 rounded-full border-2 p-1 dark:border-gray-200"
aria-label="Stop generating"
onClick={(e) => {
setShowStopButton(false);
stop(e);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="text-gizmo-gray-900 h-2 w-2 dark:text-gray-200"
height="16"
width="16"
>
<path
d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2z"
strokeWidth="0"
></path>
</svg>
</button>
</div>
);
}

View file

@ -93,7 +93,7 @@ export default function StreamAudio({ index = 0 }) {
}
console.log('Fetching audio...', navigator.userAgent);
const response = await fetch('/api/files/tts', {
const response = await fetch('/api/files/speech/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ messageId: latestMessage?.messageId, runId: activeRunId, voice }),

View file

@ -0,0 +1,20 @@
import AddedConvo from './AddedConvo';
import type { TConversation } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
export default function TextareaHeader({
addedConvo,
setAddedConvo,
}: {
addedConvo: TConversation | null;
setAddedConvo: SetterOrUpdater<TConversation | null>;
}) {
if (!addedConvo) {
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-primary-contrast">
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
</div>
);
}

View file

@ -50,12 +50,12 @@ const MenuItem: FC<MenuItemProps> = ({
}
const {
template,
shouldSwitch,
isNewModular,
newEndpointType,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
@ -63,7 +63,8 @@ const MenuItem: FC<MenuItemProps> = ({
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
const isModular = isCurrentModular && isNewModular && shouldSwitch;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType;
const currentConvo = getDefaultConversation({
@ -73,10 +74,18 @@ const MenuItem: FC<MenuItemProps> = ({
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
newConversation({
template: currentConvo,
preset: currentConvo,
keepLatestMessage: true,
keepAddedConvos: true,
});
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) } });
newConversation({
template: { ...(template as Partial<TConversation>) },
keepAddedConvos: isModular,
});
};
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');

View file

@ -29,12 +29,12 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
}
const {
template,
shouldSwitch,
isNewModular,
newEndpointType,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
@ -42,7 +42,8 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
const isModular = isCurrentModular && isNewModular && shouldSwitch;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
const currentConvo = getDefaultConversation({
@ -52,11 +53,20 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset, keepLatestMessage: true });
newConversation({
template: currentConvo,
preset,
keepLatestMessage: true,
keepAddedConvos: true,
});
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) }, preset });
newConversation({
template: { ...(template as Partial<TConversation>) },
preset,
keepAddedConvos: isModular,
});
};
const selected = useMemo(() => {

View file

@ -78,7 +78,10 @@ const PresetItems: FC<{
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
<Label
htmlFor="preset-item-clear-all"
className="text-left text-sm font-medium"
>
{localize('com_endpoint_presets_clear_warning')}
</Label>
</div>

View file

@ -2,7 +2,10 @@ import { TMessage } from 'librechat-data-provider';
import Files from './Files';
const Container = ({ children, message }: { children: React.ReactNode; message: TMessage }) => (
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto [.text-message+&]:mt-5">
<div
className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto [.text-message+&]:mt-5"
dir="auto"
>
{message.isCreatedByUser && <Files message={message} />}
{children}
</div>

View file

@ -1,12 +1,14 @@
import { useRecoilState } from 'recoil';
import TextareaAutosize from 'react-textarea-autosize';
import { EModelEndpoint } from 'librechat-data-provider';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import type { TEditProps } from '~/common';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { cn, removeFocusRings } from '~/utils';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import Container from './Container';
import store from '~/store';
const EditMessage = ({
text,
@ -17,7 +19,11 @@ const EditMessage = ({
siblingIdx,
setSiblingIdx,
}: TEditProps) => {
const { addedIndex } = useAddedChatContext();
const { getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);
const [editedText, setEditedText] = useState<string>(text ?? '');
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
@ -85,17 +91,28 @@ const EditMessage = ({
text: editedText,
messageId,
});
setMessages(
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text: editedText,
isEdited: true,
}
: msg,
),
);
if (message.messageId === latestMultiMessage?.messageId) {
setLatestMultiMessage({ ...latestMultiMessage, text: editedText });
}
const isInMessages = messages?.some((message) => message?.messageId === messageId);
if (!isInMessages) {
message.text = editedText;
} else {
setMessages(
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text: editedText,
isEdited: true,
}
: msg,
),
);
}
enterEdit(true);
};
@ -145,6 +162,7 @@ const EditMessage = ({
contentEditable={true}
value={editedText}
suppressContentEditableWarning={true}
dir="auto"
/>
</div>
<div className="mt-2 flex w-full justify-center text-center">

View file

@ -21,7 +21,7 @@ export const ErrorMessage = ({
return (
<Suspense
fallback={
<div className="text-message mb-[0.625rem] mt-1 flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto">
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
<div className="absolute">
<p className="relative">

View file

@ -44,6 +44,7 @@ const SearchContent = ({ message }: { message: TMessage }) => {
'markdown prose dark:prose-invert light w-full break-words',
message.isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
)}
dir="auto"
>
<MarkdownLite content={message.text ?? ''} />
</div>

View file

@ -39,7 +39,7 @@ export default function HoverButtons({
const { endpoint: _endpoint, endpointType } = conversation ?? {};
const endpoint = endpointType ?? _endpoint;
const [isCopied, setIsCopied] = useState(false);
const [TextToSpeech] = useRecoilState<boolean>(store.TextToSpeech);
const [TextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const {
hideEditButton,
@ -77,7 +77,7 @@ export default function HoverButtons({
{isEditableEndpoint && (
<button
className={cn(
'hover-button rounded-md p-1 text-gray-400 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',
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active text-gray-700 dark:text-gray-200' : '',
@ -93,7 +93,7 @@ export default function HoverButtons({
)}
<button
className={cn(
'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',
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
@ -108,7 +108,7 @@ export default function HoverButtons({
{regenerateEnabled ? (
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-400 hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
'hover-button active rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
@ -116,7 +116,7 @@ export default function HoverButtons({
title={localize('com_ui_regenerate')}
>
<RegenerateIcon
className="hover:text-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
className="hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
size="19"
/>
</button>
@ -131,14 +131,14 @@ export default function HoverButtons({
{continueSupported ? (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'hover-button active rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={handleContinue}
type="button"
title={localize('com_ui_continue')}
>
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
<ContinueIcon className="h-4 w-4 hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
</div>

View file

@ -1,129 +1,68 @@
import { useRecoilValue } from 'recoil';
import { useAuthContext, useMessageHelpers, useLocalize } from '~/hooks';
import React from 'react';
import { useMessageProcess } from '~/hooks';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import MessageContent from './Content/MessageContent';
import SiblingSwitch from './SiblingSwitch';
import MessageRender from './ui/MessageRender';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
export default function Message(props: TMessageProps) {
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
const { user } = useAuthContext();
const localize = useLocalize();
const {
ask,
edit,
index,
isLast,
assistant,
enterEdit,
handleScroll,
conversation,
isSubmitting,
latestMessage,
handleContinue,
copyToClipboard,
regenerateMessage,
} = useMessageHelpers(props);
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
if (!message) {
return null;
}
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
let messageLabel = '';
if (isCreatedByUser) {
messageLabel = UsernameDisplay ? user?.name || user?.username : localize('com_user_message');
} else if (assistant) {
messageLabel = assistant.name ?? 'Assistant';
} else {
messageLabel = message.sender;
}
return (
<>
const MessageContainer = React.memo(
({ handleScroll, children }: { handleScroll: () => void; children: React.ReactNode }) => {
return (
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
<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} assistant={assistant} />
</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} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={text ?? ''}
message={message}
enterEdit={enterEdit}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={
setSiblingIdx ??
(() => {
return;
})
}
/>
</div>
</div>
{isLast && isSubmitting ? null : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={message}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
{children}
</div>
);
},
);
export default function Message(props: TMessageProps) {
const {
showSibling,
conversation,
handleScroll,
siblingMessage,
latestMultiMessage,
isSubmittingFamily,
} = useMessageProcess({ message: props.message });
const { message, currentEditId, setCurrentEditId } = props;
if (!message) {
return null;
}
const { children, messageId = null } = message ?? {};
return (
<>
<MessageContainer handleScroll={handleScroll}>
{showSibling ? (
<div className="m-auto my-2 flex justify-center p-4 py-2 text-base md:gap-6">
<div className="flex w-full flex-row flex-wrap justify-between gap-1 md:max-w-5xl md:flex-nowrap md:gap-2 lg:max-w-5xl xl:max-w-6xl">
<MessageRender
{...props}
message={message}
isSubmittingFamily={isSubmittingFamily}
isCard
/>
<MessageRender
{...props}
isMultiMessage
isCard
message={siblingMessage ?? latestMultiMessage ?? undefined}
isSubmittingFamily={isSubmittingFamily}
/>
</div>
</div>
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
<MessageRender {...props} />
</div>
)}
</MessageContainer>
<MultiMessage
key={messageId}
messageId={messageId}

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, memo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TMessage, TPreset, Assistant } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
@ -6,7 +6,7 @@ import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconEndpoint } from '~/utils';
import Icon from '~/components/Endpoints/Icon';
export default function MessageIcon(
function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
assistant?: false | Assistant;
},
@ -56,3 +56,5 @@ export default function MessageIcon(
/>
);
}
export default memo(MessageIcon);

View file

@ -83,7 +83,9 @@ export default function Message(props: TMessageProps) {
/>
</div>
</div>
{isLast && isSubmitting ? null : (
{isLast && isSubmitting ? (
<div className="mt-1 h-[27px] bg-transparent" />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { useEffect, useCallback } from 'react';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
@ -16,9 +16,12 @@ export default function MultiMessage({
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = (value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
};
const setSiblingIdxRev = useCallback(
(value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
},
[messagesTree?.length, setSiblingIdx],
);
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.

View file

@ -1,4 +1,5 @@
import type { TMessageProps } from '~/common';
import { cn } from '~/utils';
type TSiblingSwitchProps = Pick<TMessageProps, 'siblingIdx' | 'siblingCount' | 'setSiblingIdx'>;
@ -24,7 +25,10 @@ export default function SiblingSwitch({
return siblingCount > 1 ? (
<div className="visible flex items-center justify-center gap-1 self-center pt-0 text-xs">
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
)}
type="button"
onClick={previous}
disabled={siblingIdx == 0}
>
@ -35,7 +39,7 @@ export default function SiblingSwitch({
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
@ -47,7 +51,10 @@ export default function SiblingSwitch({
{siblingIdx + 1} / {siblingCount}
</span>
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
)}
type="button"
onClick={next}
disabled={siblingIdx == siblingCount - 1}
>
@ -58,7 +65,7 @@ export default function SiblingSwitch({
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,154 @@
import React, { useCallback, useMemo } from 'react';
import { useMessageActions } from '~/hooks';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn } from '~/utils';
type MessageRenderProps = {
message?: TMessage;
isCard?: boolean;
isMultiMessage?: boolean;
isSubmittingFamily?: boolean;
} & Pick<
TMessageProps,
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
const MessageRender = React.memo(
({
isCard,
siblingIdx,
siblingCount,
message: msg,
setSiblingIdx,
currentEditId,
isMultiMessage,
setCurrentEditId,
isSubmittingFamily,
}: MessageRenderProps) => {
const {
ask,
edit,
index,
assistant,
enterEdit,
conversation,
messageLabel,
isSubmitting,
latestMessage,
handleContinue,
copyToClipboard,
setLatestMessage,
regenerateMessage,
} = useMessageActions({
message: msg,
currentEditId,
isMultiMessage,
setCurrentEditId,
});
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const { isCreatedByUser, error, unfinished } = msg ?? {};
const isLast = useMemo(
() => !msg?.children?.length && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[msg?.children, msg?.depth, latestMessage?.depth],
);
if (!msg) {
return null;
}
const isLatestCard =
isCard && !isSubmittingFamily && msg.messageId === latestMessage?.messageId;
const clickHandler =
isLast && isCard && !isSubmittingFamily && msg.messageId !== latestMessage?.messageId
? () => setLatestMessage(msg)
: undefined;
return (
<div
className={cn(
'final-completion group mx-auto flex flex-1 gap-3 text-base',
isCard
? 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4'
: 'md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5',
isLatestCard ? 'bg-surface-secondary' : '',
isLast && !isSubmittingFamily && isCard
? 'cursor-pointer transition-colors duration-300'
: '',
)}
onClick={clickHandler}
>
{isLatestCard && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary"></div>
)}
<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={msg} conversation={conversation} assistant={assistant} />
</div>
</div>
</div>
</div>
<div
className={cn('relative flex w-11/12 flex-col', msg?.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">
{msg?.plugin && <Plugin plugin={msg?.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={msg.text ?? ''}
message={msg}
enterEdit={enterEdit}
error={!!error}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</div>
</div>
{!msg?.children?.length && (isSubmittingFamily || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
);
},
);
export default MessageRender;

View file

@ -0,0 +1,10 @@
import { memo } from 'react';
const PlaceholderRow = memo(({ isCard }: { isCard?: boolean }) => {
if (!isCard) {
return null;
}
return <div className="mt-1 h-[27px] bg-transparent" />;
});
export default PlaceholderRow;

View file

@ -0,0 +1,15 @@
import { TPromptGroup } from 'librechat-data-provider';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
export default function PromptCard({ promptGroup }: { promptGroup: TPromptGroup }) {
return (
<div className="hover:bg-token-main-surface-secondary relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition transition-colors duration-300 ease-in-out fade-in hover:bg-slate-100 dark:border-gray-600 dark:hover:bg-gray-700">
<div className="">
<CategoryIcon className="size-4" category={promptGroup.category || ''} />
</div>
<p className="break-word line-clamp-3 text-balance text-gray-600 dark:text-gray-400">
{promptGroup?.oneliner || promptGroup?.productionPrompt?.prompt}
</p>
</div>
);
}

View file

@ -0,0 +1,62 @@
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { TooltipProvider, Tooltip } from '~/components/ui';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { getIconEndpoint, cn } from '~/utils';
import Prompts from './Prompts';
export default function Landing({ Header }: { Header?: ReactNode }) {
const { conversation } = useChatContext();
const assistantMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
let { endpoint = '' } = conversation ?? {};
const { assistant_id = null } = conversation ?? {};
if (
endpoint === EModelEndpoint.chatGPTBrowser ||
endpoint === EModelEndpoint.azureOpenAI ||
endpoint === EModelEndpoint.gptPlugins
) {
endpoint = EModelEndpoint.openAI;
}
const iconURL = conversation?.iconURL;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const isAssistant = isAssistantsEndpoint(endpoint);
const assistant = isAssistant && assistantMap?.[endpoint]?.[assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<div className="relative h-full">
<div className="absolute left-0 right-0">{Header && Header}</div>
<div className="flex h-full flex-col items-center justify-center">
<div className={cn('relative h-12 w-12', assistantName && avatar ? 'mb-0' : 'mb-3')}>
<ConvoIcon
conversation={conversation}
assistantMap={assistantMap}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
size={41}
/>
</div>
<div className="h-3/5">
<Prompts />
</div>
</div>
</div>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -0,0 +1,95 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { usePromptGroupsNav } from '~/hooks';
import PromptCard from './PromptCard';
import { Button } from '../ui';
export default function Prompts() {
const { prevPage, nextPage, hasNextPage, promptGroups, hasPreviousPage, setPageSize, pageSize } =
usePromptGroupsNav();
const renderPromptCards = (start = 0, count) => {
return promptGroups
.slice(start, count + start)
.map((promptGroup) => <PromptCard key={promptGroup._id} promptGroup={promptGroup} />);
};
const getRows = () => {
switch (pageSize) {
case 4:
return [4];
case 8:
return [4, 4];
case 12:
return [4, 4, 4];
default:
return [];
}
};
const rows = getRows();
return (
<div className="mx-3 flex h-full max-w-3xl flex-col items-stretch justify-center gap-4">
<div className="mt-2 flex justify-end gap-2">
<Button
variant={'ghost'}
onClick={() => setPageSize(4)}
className={`rounded px-3 py-2 hover:bg-transparent ${
pageSize === 4 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
4
</Button>
<Button
variant={'ghost'}
onClick={() => setPageSize(8)}
className={`rounded px-3 py-2 hover:bg-transparent ${
pageSize === 8 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
8
</Button>
<Button
variant={'ghost'}
onClick={() => setPageSize(12)}
className={`rounded p-2 hover:bg-transparent ${
pageSize === 12 ? 'text-white' : 'text-gray-500 dark:text-gray-500'
}`}
>
12
</Button>
</div>
<div className="flex h-full flex-col items-start gap-2">
<div
className={
'flex min-h-[121.1px] min-w-full max-w-3xl flex-col gap-4 overflow-y-auto md:min-w-[22rem] lg:min-w-[43rem]'
}
>
{rows.map((rowSize, index) => (
<div key={index} className="flex flex-wrap justify-center gap-4">
{renderPromptCards(rowSize * index, rowSize)}
</div>
))}
</div>
<div className="flex w-full justify-between">
<Button
variant={'ghost'}
onClick={prevPage}
disabled={!hasPreviousPage}
className="m-0 self-start p-0 hover:bg-transparent"
>
<ChevronLeft className={`${hasPreviousPage ? '' : 'text-gray-500'}`} />
</Button>
<Button
variant={'ghost'}
onClick={nextPage}
disabled={!hasNextPage}
className="m-0 self-end p-0 hover:bg-transparent"
>
<ChevronRight className={`${hasNextPage ? '' : 'text-gray-500'}`} />
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,156 +0,0 @@
import { useState, useRef } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useUpdateConversationMutation } from '~/data-provider';
import { useConversations, useConversation } from '~/hooks';
import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import DeleteButton from './DeleteButton';
import RenameButton from './RenameButton';
import store from '~/store';
export default function Conversation({ conversation, retainView }) {
const { showToast } = useToastContext();
const [currentConversation, setCurrentConversation] = useRecoilState(store.conversation);
const setSubmission = useSetRecoilState(store.submission);
const { refreshConversations } = useConversations();
const { switchToConversation } = useConversation();
const updateConvoMutation = useUpdateConversationMutation(currentConversation?.conversationId);
const [renaming, setRenaming] = useState(false);
const inputRef = useRef(null);
const { conversationId, title } = conversation;
const [titleInput, setTitleInput] = useState(title);
const clickHandler = async () => {
if (currentConversation?.conversationId === conversationId) {
return;
}
// stop existing submission
setSubmission(null);
// set document title
document.title = title;
// set conversation to the new conversation
if (conversation?.endpoint === 'gptPlugins') {
const lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools')) || [];
switchToConversation({ ...conversation, tools: lastSelectedTools });
} else {
switchToConversation(conversation);
}
};
const renameHandler = (e) => {
e.preventDefault();
setTitleInput(title);
setRenaming(true);
setTimeout(() => {
inputRef.current.focus();
}, 25);
};
const cancelHandler = (e) => {
e.preventDefault();
setRenaming(false);
};
const onRename = (e) => {
e.preventDefault();
setRenaming(false);
if (titleInput === title) {
return;
}
updateConvoMutation.mutate(
{ conversationId, title: titleInput },
{
onSuccess: () => {
refreshConversations();
if (conversationId == currentConversation?.conversationId) {
setCurrentConversation((prevState) => ({
...prevState,
title: titleInput,
}));
}
},
onError: () => {
setTitleInput(title);
showToast({
message: 'Failed to rename conversation',
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
};
const icon = MinimalIcon({
size: 20,
endpoint: conversation.endpoint,
model: conversation.model,
error: false,
className: 'mr-0',
});
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
onRename(e);
}
};
const aProps = {
className:
'animate-flash group relative flex cursor-pointer items-center gap-3 break-all rounded-md bg-gray-300 dark:bg-gray-800 py-3 px-3 pr-14',
};
if (currentConversation?.conversationId !== conversationId) {
aProps.className =
'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-200 dark:hover:bg-gray-800 hover:pr-4';
}
return (
<a data-testid="convo-item" onClick={() => clickHandler()} {...aProps}>
{icon}
<div className="relative max-h-5 flex-1 overflow-hidden text-ellipsis break-all">
{renaming === true ? (
<input
ref={inputRef}
type="text"
className="m-0 mr-0 w-full border border-blue-500 bg-transparent p-0 text-sm leading-tight outline-none"
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
onBlur={onRename}
onKeyDown={handleKeyDown}
/>
) : (
title
)}
</div>
{currentConversation?.conversationId === conversationId ? (
<div className="visible absolute right-1 z-10 flex text-gray-300">
<RenameButton
conversationId={conversationId}
renaming={renaming}
renameHandler={renameHandler}
onRename={onRename}
/>
<DeleteButton
conversationId={conversationId}
renaming={renaming}
cancelHandler={cancelHandler}
retainView={retainView}
title={title}
/>
</div>
) : (
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-gray-50 group-hover:from-gray-50 dark:from-gray-900 dark:group-hover:from-gray-800" />
)}
</a>
);
}

View file

@ -168,7 +168,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
className={cn(
isActiveConvo || isPopoverActive
? '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',
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all 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' : '',
)}
title={title}
@ -190,7 +190,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
)}
/>
) : (
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-60% dark:from-[#181818] dark:group-hover:from-gray-700" />
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 group-hover:from-60% dark:from-gray-850 dark:group-hover:from-gray-700" />
)}
</a>
</div>

View file

@ -84,8 +84,8 @@ export default function DeleteButton({
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
{localize('com_ui_delete_conversation_confirm')} <strong>{title}</strong>
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{title}</strong>
</Label>
</div>
</div>

View file

@ -165,8 +165,8 @@ export default function Fork({
<Popover.Trigger asChild>
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'data-[state=open]:active data-[state=open]:bg-gray-200 data-[state=open]:text-gray-700 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'data-[state=open]:active data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast ? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={(e) => {
@ -184,7 +184,7 @@ export default function Fork({
type="button"
title={localize('com_ui_fork')}
>
<GitFork className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
</Popover.Trigger>
<Popover.Portal>

View file

@ -8,23 +8,26 @@ const HoverToggle = ({
isPopoverActive,
setIsPopoverActive,
className = 'absolute bottom-0 right-0 top-0',
onClick,
}: {
children: React.ReactNode;
isActiveConvo: boolean;
isPopoverActive: boolean;
setIsPopoverActive: (isActive: boolean) => void;
className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}) => {
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
return (
<ToggleContext.Provider value={{ isPopoverActive, setPopoverActive }}>
<div
onClick={onClick}
className={cn(
'peer items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',
'peer items-center gap-1.5 rounded-r-lg from-gray-900 pl-2 pr-2 dark:text-white',
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex',
isActiveConvo
? 'from-gray-50 from-85% to-transparent group-hover:bg-gradient-to-l group-hover:from-gray-200 dark:from-gray-800 dark:group-hover:from-gray-800'
: 'z-50 from-gray-200 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-750 dark:from-gray-800 dark:hover:from-gray-800',
: 'z-50 from-gray-50 from-0% to-transparent hover:bg-gradient-to-l hover:from-gray-200 dark:from-gray-800 dark:hover:from-gray-800',
isPopoverActive && !isActiveConvo ? 'from-gray-50 dark:from-gray-800' : '',
className,
)}

View file

@ -6,17 +6,19 @@ import { cn } from '~/utils';
interface RenameButtonProps {
renaming: boolean;
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
onRename?: (e: MouseEvent<HTMLButtonElement>) => void;
appendLabel?: boolean;
className?: string;
disabled?: boolean;
}
export default function RenameButton({
renaming,
renameHandler,
onRename,
appendLabel = false,
renameHandler,
className = '',
disabled = false,
appendLabel = false,
}: RenameButtonProps): ReactElement {
const localize = useLocalize();
const handler = renaming ? onRename : renameHandler;
@ -27,6 +29,7 @@ export default function RenameButton({
'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,
)}
disabled={disabled}
onClick={handler}
>
{renaming ? (

View file

@ -1,8 +1,10 @@
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 { useUpdateSharedLinkMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
@ -21,8 +23,18 @@ export default function SharedLinkButton({
setIsUpdated: (isUpdated: boolean) => void;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isCopying, setIsCopying] = useState(false);
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation();
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const copyLink = () => {
if (!share) {

View file

@ -1,5 +1,4 @@
export { default as Fork } from './Fork';
export { default as Pages } from './Pages';
export { default as Conversation } from './Conversation';
export { default as RenameButton } from './RenameButton';
export { default as Conversations } from './Conversations';

View file

@ -1,10 +1,11 @@
import { UserIcon } from '~/components/svg';
import { memo } from 'react';
import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon';
import { useAuthContext } from '~/hooks/AuthContext';
import useAvatar from '~/hooks/Messages/useAvatar';
import useLocalize from '~/hooks/useLocalize';
import { IconProps } from '~/common';
import { UserIcon } from '~/components/svg';
import { cn } from '~/utils';
import MessageEndpointIcon from './MessageEndpointIcon';
const Icon: React.FC<IconProps> = (props) => {
const { user } = useAuthContext();
@ -46,4 +47,4 @@ const Icon: React.FC<IconProps> = (props) => {
return <MessageEndpointIcon {...props} />;
};
export default Icon;
export default memo(Icon);

View file

@ -1,13 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useCreatePresetMutation } from 'librechat-data-provider/react-query';
import type { TEditPresetProps } from '~/common';
import {
cn,
defaultTextPropsLabel,
removeFocusOutlines,
cleanupPreset,
defaultTextProps,
} from '~/utils/';
import { cn, removeFocusOutlines, cleanupPreset, defaultTextProps } from '~/utils/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { Dialog, Input, Label } from '~/components/ui/';
import { NotificationSeverity } from '~/common';
@ -61,7 +55,7 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
<Label htmlFor="dialog-preset-name" className="text-left text-sm font-medium">
{localize('com_endpoint_preset_name')}
</Label>
<Input

View file

@ -0,0 +1,20 @@
import React from 'react';
import { CrossIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type ActionButtonProps = {
onClick: () => void;
};
export default function ActionButton({ onClick }: ActionButtonProps) {
return (
<div className="w-32">
<Button
className="w-full rounded-md border border-black bg-white p-0 text-black hover:bg-black hover:text-white"
onClick={onClick}
>
Action Button
</Button>
</div>
);
}

View file

@ -0,0 +1,17 @@
import React from 'react';
import { CrossIcon, NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type DeleteIconButtonProps = {
onClick: () => void;
};
export default function DeleteIconButton({ onClick }: DeleteIconButtonProps) {
return (
<div className="w-fit">
<Button className="bg-red-400 p-3" onClick={onClick}>
<NewTrashIcon />
</Button>
</div>
);
}

View file

@ -0,0 +1,39 @@
import React from 'react';
import VectorStoreSidePanel from './VectorStore/VectorStoreSidePanel';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
import { Button } from '../ui';
const FileDashboardView = () => {
const params = useParams();
const navigate = useNavigate();
return (
<div className="bg-[#f9f9f9] p-0 lg:p-7">
<div className="ml-3 mt-3 flex flex-row justify-between">
{params?.vectorStoreId && (
<Button
className="block lg:hidden"
variant={'outline'}
size={'sm'}
onClick={() => {
navigate('/d');
}}
>
Go back
</Button>
)}
</div>
<div className="flex h-screen max-w-full flex-row divide-x bg-[#f9f9f9]">
<div className={`w-full lg:w-1/3 ${params.vectorStoreId ? 'hidden lg:block' : ''}`}>
<VectorStoreSidePanel />
</div>
<div className={`w-full lg:w-2/3 ${params.vectorStoreId ? '' : 'hidden lg:block'}`}>
<div className="m-2 overflow-x-auto">
<Outlet />
</div>
</div>
</div>
</div>
);
};
export default FileDashboardView;

View file

@ -0,0 +1,277 @@
import * as React from 'react';
import { ListFilter } from 'lucide-react';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type {
ColumnDef,
SortingState,
VisibilityState,
ColumnFiltersState,
} from '@tanstack/react-table';
import { FileContext } from 'librechat-data-provider';
import type { AugmentedColumnDef } from '~/common';
import type { TFile } from 'librechat-data-provider';
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '~/components/ui';
import { useDeleteFilesFromTable } from '~/hooks/Files';
import { NewTrashIcon, Spinner } from '~/components/svg';
import useLocalize from '~/hooks/useLocalize';
import ActionButton from '../ActionButton';
import UploadFileButton from './UploadFileButton';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
const contextMap = {
[FileContext.filename]: 'com_ui_name',
[FileContext.updatedAt]: 'com_ui_date',
[FileContext.source]: 'com_ui_storage',
[FileContext.context]: 'com_ui_context',
[FileContext.bytes]: 'com_ui_size',
};
type Style = { width?: number | string; maxWidth?: number | string; minWidth?: number | string };
export default function DataTableFile<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const localize = useLocalize();
const [isDeleting, setIsDeleting] = React.useState(false);
const [rowSelection, setRowSelection] = React.useState({});
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<>
<div className="mt-2 flex flex-col items-start">
<h2 className="text-lg">
<strong>Files</strong>
</h2>
<div className="mt-3 flex w-full flex-col-reverse justify-between md:flex-row">
<div className="mt-3 flex w-full flex-row justify-center gap-x-3 md:m-0 md:justify-start">
<ActionButton
onClick={() => {
console.log('click');
}}
/>
<Button
variant="ghost"
onClick={() => {
setIsDeleting(true);
const filesToDelete = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original);
deleteFiles({ files: filesToDelete as TFile[] });
setRowSelection({});
}}
className="ml-1 gap-2 dark:hover:bg-gray-850/25 sm:ml-0"
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
>
{isDeleting ? (
<Spinner className="h-4 w-4" />
) : (
<NewTrashIcon className="h-4 w-4 text-red-400" />
)}
{localize('com_ui_delete')}
</Button>
</div>
<div className="flex w-full flex-row gap-x-3">
{' '}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-auto">
<ListFilter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="z-[1001] dark:border-gray-700 dark:bg-gray-850"
>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{localize(contextMap[column.id])}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Input
placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="max-w-sm dark:border-gray-500"
/>
<UploadFileButton
onClick={() => {
console.log('click');
}}
/>
</div>
</div>
</div>
<div className="relative mt-3 max-h-[25rem] min-h-0 overflow-y-auto rounded-md border border-black/10 pb-4 dark:border-white/10 sm:min-h-[28rem]">
<Table className="w-full min-w-[600px] border-separate border-spacing-0">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const style: Style = { maxWidth: '32px', minWidth: '125px' };
if (header.id === 'filename') {
style.maxWidth = '25%';
style.width = '25%';
style.minWidth = '150px';
}
if (header.id === 'icon') {
style.width = '25px';
style.maxWidth = '25px';
style.minWidth = '35px';
}
if (header.id === 'vectorStores') {
style.maxWidth = '50%';
style.width = '50%';
style.minWidth = '300px';
}
if (index === 0 && header.id === 'select') {
style.width = '25px';
style.maxWidth = '25px';
style.minWidth = '35px';
}
return (
<TableHead
key={header.id}
className="align-start sticky top-0 rounded-t border-b border-black/10 bg-white px-2 py-1 text-left font-medium text-gray-700 dark:border-white/10 dark:bg-gray-700 dark:text-gray-100 sm:px-4 sm:py-2"
style={style}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="border-b border-black/10 text-left text-gray-600 dark:border-white/10 dark:text-gray-300 [tr:last-child_&]:border-b-0"
>
{row.getVisibleCells().map((cell, index) => {
const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>)?.meta?.size ??
'auto';
const style: Style = {};
if (cell.column.id === 'filename') {
style.maxWidth = maxWidth;
} else if (index === 0) {
style.maxWidth = '20px';
}
return (
<TableCell
key={cell.id}
className="align-start overflow-x-auto px-2 py-1 text-xs sm:px-4 sm:py-2 sm:text-sm [tr[data-disabled=true]_&]:opacity-50"
style={style}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{localize('com_files_no_results')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="ml-4 mr-4 mt-4 flex h-auto items-center justify-end space-x-2 py-4 sm:ml-0 sm:mr-0 sm:h-0">
<div className="text-muted-foreground ml-2 flex-1 text-sm">
{localize(
'com_files_number_selected',
`${table.getFilteredSelectedRowModel().rows.length}`,
`${table.getFilteredRowModel().rows.length}`,
)}
</div>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{localize('com_ui_prev')}
</Button>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{localize('com_ui_next')}
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,81 @@
import React from 'react';
import DataTableFile from './DataTableFile';
import { TVectorStore } from '~/common';
import { files } from '../../Chat/Input/Files/Table';
import { fileTableColumns } from './../FileList/FileTableColumns';
const vectorStoresAttached: TVectorStore[] = [
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
{
name: 'vector 1 vector 1',
created_at: '2022-01-01T10:00:00',
_id: 'id',
object: 'vector_store',
},
];
files.forEach((file) => {
file['vectorsAttached'] = vectorStoresAttached;
});
export default function DataTableFilePreview() {
return (
<div>
<DataTableFile columns={fileTableColumns} data={files} />
<div className="mt-5 sm:mt-4" />
</div>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
export default function EmptyFilePreview() {
return (
<div className="h-full w-full content-center text-center font-bold">
Select a file to view details.
</div>
);
}

View file

@ -0,0 +1,26 @@
import type { TFile } from 'librechat-data-provider';
import React from 'react';
import FileListItem from './FileListItem';
import FileListItem2 from './FileListItem2';
type FileListProps = {
files: TFile[];
deleteFile: (id: string | undefined) => void;
attachedVectorStores: { name: string }[];
};
export default function FileList({ files, deleteFile, attachedVectorStores }: FileListProps) {
return (
<div className="h-[85vh] overflow-y-auto">
{files.map((file) => (
// <FileListItem key={file._id} file={file} deleteFile={deleteFile} width="100%" />
<FileListItem2
key={file._id}
file={file}
deleteFile={deleteFile}
attachedVectorStores={attachedVectorStores}
/>
))}
</div>
);
}

View file

@ -0,0 +1,33 @@
import type { TFile } from 'librechat-data-provider';
import React from 'react';
import { NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type FileListItemProps = {
file: TFile;
deleteFile: (id: string | undefined) => void;
width?: string;
};
export default function FileListItem({ file, deleteFile, width = '400px' }: FileListItemProps) {
return (
<div className="w-100 my-3 mr-2 flex cursor-pointer flex-row rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200">
<div className="flex w-1/2 flex-col justify-around align-middle">
<strong>{file.filename}</strong>
<p className="text-sm text-gray-500">{file.object}</p>
</div>
<div className="w-2/6 text-gray-500">
<p>({file.bytes / 1000}KB)</p>
<p className="text-sm">{file.createdAt?.toString()}</p>
</div>
<div className="flex w-1/6 justify-around">
<Button
className="my-0 ml-3 bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => deleteFile(file._id)}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,76 @@
import type { TFile } from 'librechat-data-provider';
import { FileIcon, PlusIcon } from 'lucide-react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { DotsIcon, NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type FileListItemProps = {
file: TFile;
deleteFile: (id: string | undefined) => void;
attachedVectorStores: { name: string }[];
};
export default function FileListItem2({
file,
deleteFile,
attachedVectorStores,
}: FileListItemProps) {
const navigate = useNavigate();
return (
<div
onClick={() => {
navigate('file_id_abcdef');
}}
className="w-100 mt-2 flex h-fit cursor-pointer flex-row rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200"
>
<div className="flex w-10/12 flex-col justify-around md:flex-row">
<div className="flex w-2/5 flex-row">
<div className="w-1/4 content-center">
<FileIcon className="m-0 size-5 p-0" />
</div>
<div className="w-3/4 content-center">{file.filename}</div>
</div>
<div className="flex w-fit flex-row flex-wrap text-gray-500 md:w-3/5">
{attachedVectorStores.map((vectorStore, index) => {
if (index === 4) {
return (
<span
key={index}
className="ml-2 mt-1 flex flex-row items-center rounded-full bg-[#f5f5f5] px-2 text-xs"
>
<PlusIcon className="h-3 w-3" />
&nbsp;
{attachedVectorStores.length - index} more
</span>
);
}
if (index > 4) {
return null;
}
return (
<span
key={index}
className="ml-2 mt-1 content-center rounded-full bg-[#f2f8ec] px-2 text-xs text-[#91c561]"
>
{vectorStore.name}
</span>
);
})}
</div>
</div>
<div className="mr-0 flex w-2/12 flex-col items-center justify-evenly sm:mr-4 md:flex-row">
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">
<DotsIcon className="text-grey-100" />
</Button>
<Button
className="w-min bg-transparent text-[#666666] hover:bg-slate-200"
onClick={() => deleteFile(file._id)}
>
<NewTrashIcon className="" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,180 @@
import { TFile } from 'librechat-data-provider/dist/types';
import React, { useState } from 'react';
import { TThread, TVectorStore } from '~/common';
import { CheckMark, NewTrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
import DeleteIconButton from '../DeleteIconButton';
import VectorStoreButton from '../VectorStore/VectorStoreButton';
import { CircleIcon, Clock3Icon, InfoIcon } from 'lucide-react';
import { useParams } from 'react-router-dom';
const tempFile: TFile = {
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
};
const tempThreads: TThread[] = [
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
{ id: 'thead_id', createdAt: '2022-01-01T10:00:00' },
];
const tempVectorStoresAttached: TVectorStore[] = [
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
{ name: 'vector 1', created_at: '2022-01-01T10:00:00', _id: 'id', object: 'vector_store' },
];
export default function FilePreview() {
const [file, setFile] = useState(tempFile);
const [threads, setThreads] = useState(tempThreads);
const [vectorStoresAttached, setVectorStoresAttached] = useState(tempVectorStoresAttached);
const params = useParams();
return (
<div className="m-3 bg-white p-2 sm:p-4 md:p-6 lg:p-10">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col">
<b className="hidden text-sm md:text-base lg:block lg:text-lg">FILE</b>
<b className="text-center text-xl md:text-2xl lg:text-left lg:text-3xl">
{file.filename}
</b>
</div>
<div className="mt-3 flex flex-row gap-x-3 md:mt-0">
<div>
<DeleteIconButton
onClick={() => {
console.log('click');
}}
/>
</div>
<div className="w-40">
<VectorStoreButton
onClick={() => {
console.log('click');
}}
/>
</div>
</div>
</div>
<div className="mt-3 flex flex-col">
<div className="flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<InfoIcon className="size-4 text-gray-500" />
&nbsp; File ID
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file._id}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<CircleIcon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Status
</span>
<div className="w-1/2 sm:w-3/4 md:w-3/5">
<span className="flex w-20 flex-row items-center justify-evenly rounded-full bg-[#f2f8ec] p-1 text-[#91c561]">
<CheckMark className="m-0 p-0" />
<div>{file.object}</div>
</span>
</div>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Purpose
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file.message}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Size
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">{file.bytes}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center sm:w-1/4 md:w-2/5">
<Clock3Icon className="m-0 size-4 p-0 text-gray-500" />
&nbsp; Created At
</span>
<span className="w-1/2 text-gray-500 sm:w-3/4 md:w-3/5">
{file.createdAt?.toString()}
</span>
</div>
</div>
<div className="mt-10 flex flex-col">
<div>
<b className="text-sm md:text-base lg:text-lg">Attached To</b>
</div>
<div className="flex flex-col divide-y">
<div className="mt-2 flex flex-row">
<div className="w-2/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-2/3">
Vector Stores
</div>
<div className="w-3/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-1/3">Uploaded</div>
</div>
<div>
{vectorStoresAttached.map((vectors, index) => (
<div key={index} className="mt-2 flex flex-row">
<div className="ml-4 w-2/5 content-center md:w-1/2 xl:w-2/3">{vectors.name}</div>
<div className="flex w-3/5 flex-row md:w-1/2 xl:w-1/3">
<div className="content-center text-nowrap">{vectors.created_at.toString()}</div>
<Button
className="m-0 ml-3 h-full bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => {
console.log('Remove from vector store');
}}
variant={'ghost'}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div className="mt-10 flex flex-col">
<div className="flex flex-col divide-y">
<div className="flex flex-row">
<div className="w-2/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-2/3">Threads</div>
<div className="w-3/5 text-sm md:w-1/2 md:text-base lg:text-lg xl:w-1/3">Uploaded</div>
</div>
<div>
{threads.map((thread, index) => (
<div key={index} className="mt-2 flex flex-row">
<div className="ml-4 w-2/5 content-center md:w-1/2 xl:w-2/3">ID: {thread.id}</div>
<div className="flex w-3/5 flex-row md:w-1/2 xl:w-1/3">
<div className="content-center text-nowrap">{thread.createdAt}</div>
<Button
className="m-0 ml-3 h-full bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => {
console.log('Remove from thread');
}}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,187 @@
import React from 'react';
import FileList from './FileList';
import { TFile } from 'librechat-data-provider/dist/types';
import FilesSectionSelector from '../FilesSectionSelector';
import { Button, Input } from '~/components/ui';
import { ListFilter } from 'lucide-react';
import UploadFileButton from './UploadFileButton';
import { useLocalize } from '~/hooks';
const fakeFiles = [
{
filename: 'File1.jpg',
object: 'Description 1',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
},
{
filename: 'File2.jpg',
object: 'Description 2',
bytes: 15000,
createdAt: '2022-01-02T15:30:00',
_id: '2',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
{
filename: 'File3.jpg',
object: 'Description 3',
bytes: 20000,
createdAt: '2022-01-03T09:45:00',
_id: '3',
},
];
const attachedVectorStores = [
{ name: 'VectorStore1' },
{ name: 'VectorStore2' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
{ name: 'VectorStore3' },
];
export default function FileSidePanel() {
const localize = useLocalize();
const deleteFile = (id: string | undefined) => {
// Define delete functionality here
console.log(`Deleting File with id: ${id}`);
};
return (
<div className="w-30">
<h2 className="m-3 text-lg">
<strong>Files</strong>
</h2>
<div className="m-3 mt-2 flex w-full flex-row justify-between gap-x-2 lg:m-0">
<div className="flex w-2/3 flex-row">
<Button variant="ghost" className="m-0 mr-2 p-0">
<ListFilter className="h-4 w-4" />
</Button>
<Input
placeholder={localize('com_files_filter')}
value={''}
onChange={() => {
console.log('changed');
}}
className="max-w-sm dark:border-gray-500"
/>
</div>
<div className="w-1/3">
<UploadFileButton
onClick={() => {
console.log('Upload');
}}
/>
</div>
</div>
<div className="mt-3">
<FileList
files={fakeFiles as TFile[]}
deleteFile={deleteFile}
attachedVectorStores={attachedVectorStores}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,126 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { FileSources, FileContext } from 'librechat-data-provider';
import type { ColumnDef } from '@tanstack/react-table';
import type { TFile } from 'librechat-data-provider';
import { CrossIcon, DotsIcon } from '~/components/svg';
import { Button, Checkbox } from '~/components/ui';
import { formatDate, getFileType } from '~/utils';
import useLocalize from '~/hooks/useLocalize';
import FileIcon from '~/components/svg/Files/FileIcon';
import { PlusIcon } from 'lucide-react';
export const fileTableColumns: ColumnDef<TFile>[] = [
{
id: 'select',
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="flex"
/>
);
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="flex"
/>
);
},
enableSorting: false,
enableHiding: false,
},
{
meta: {
size: '50px',
},
accessorKey: 'icon',
header: () => {
return 'Icon';
},
cell: ({ row }) => {
const file = row.original;
return <FileIcon file={file} fileType={getFileType(file.type)} />;
},
},
{
meta: {
size: '150px',
},
accessorKey: 'filename',
header: ({ column }) => {
const localize = useLocalize();
return <>{localize('com_ui_name')}</>;
},
cell: ({ row }) => {
const file = row.original;
return <span className="self-center truncate">{file.filename}</span>;
},
},
{
accessorKey: 'vectorStores',
header: () => {
return 'Vector Stores';
},
cell: ({ row }) => {
const { vectorsAttached: attachedVectorStores } = row.original;
return (
<>
{attachedVectorStores.map((vectorStore, index) => {
if (index === 4) {
return (
<span
key={index}
className="ml-2 mt-2 flex w-fit flex-row items-center rounded-full bg-[#f5f5f5] px-2 text-gray-500"
>
<PlusIcon className="h-3 w-3" />
&nbsp;
{attachedVectorStores.length - index} more
</span>
);
}
if (index > 4) {
return null;
}
return (
<span key={index} className="ml-2 mt-2 rounded-full bg-[#f2f8ec] px-2 text-[#91c561]">
{vectorStore.name}
</span>
);
})}
</>
);
},
},
{
accessorKey: 'updatedAt',
header: () => {
const localize = useLocalize();
return 'Modified';
},
cell: ({ row }) => formatDate(row.original.updatedAt),
},
{
accessorKey: 'actions',
header: () => {
return 'Actions';
},
cell: ({ row }) => {
return (
<>
<Button className="w-min content-center bg-transparent text-gray-500 hover:bg-slate-200">
<DotsIcon className="text-grey-100 m-0 size-5 p-0" />
</Button>
</>
);
},
},
];

View file

@ -0,0 +1,18 @@
import { PlusIcon } from 'lucide-react';
import React from 'react';
import { Button } from '~/components/ui';
type UploadFileProps = {
onClick: () => void;
};
export default function UploadFileButton({ onClick }: UploadFileProps) {
return (
<div className="w-full">
<Button className="w-full bg-black px-3 text-white" onClick={onClick}>
<PlusIcon className="h-4 w-4 font-bold" />
&nbsp; <span className="text-nowrap">Upload New File</span>
</Button>
</div>
);
}

View file

@ -0,0 +1,88 @@
import React, { useState, ChangeEvent } from 'react';
import AttachFile from '~/components/Chat/Input/Files/AttachFile';
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const UploadFileModal = ({ open, onOpenChange }) => {
const localize = useLocalize();
const [file, setFile] = useState<File | null>(null);
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0];
setFile(selectedFile);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'w-11/12 overflow-x-auto p-3 shadow-2xl dark:bg-gray-700 dark:text-white lg:w-2/3 xl:w-2/5',
)}
>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
Upoad a File
</DialogTitle>
</DialogHeader>
<div className="flex w-full flex-col p-0 sm:p-6 sm:pb-0 sm:pt-4">
<div className="flex w-full flex-row">
<div className="hidden w-1/5 sm:block">
<img />
</div>
<div className="flex w-full flex-col text-center sm:w-4/5 sm:text-left">
<div className="italic">Please upload square file, size less than 100KB</div>
<div className="mt-4 flex w-full flex-row items-center bg-[#f9f9f9] p-2">
<div className="w-1/2 sm:w-1/3">
<Button>Choose File</Button>
</div>
<div className="w-1/2 sm:w-1/3"> No File Chosen</div>
</div>
</div>
</div>
<div className="mt-3 flex w-full flex-col">
<label htmlFor="name">Name</label>
<label className="hidden text-[#808080] sm:block">The name of the uploaded file</label>
<Input type="text" id="name" name="name" placeholder="Name" />
</div>
<div className="mt-3 flex w-full flex-col">
<label htmlFor="purpose">Purpose</label>
<label className="hidden text-[#808080] sm:block">
The purpose of the uploaded file
</label>
<Input type="text" id="purpose" name="purpose" placeholder="Purpose" />
</div>
<div className="mt-3 flex w-full flex-row justify-between">
<div className="hidden w-1/3 sm:block">
<span className="font-bold">Learn about file purpose</span>
</div>
<div className="flex w-full flex-row justify-evenly sm:w-1/3">
<Button
className="mr-3 w-full rounded-md border border-black bg-white p-0 text-black hover:bg-white"
onClick={() => {
onOpenChange(false);
}}
>
Cancel
</Button>
<Button
className="w-full rounded-md border border-black bg-black p-0 text-white"
onClick={() => {
console.log('upload file');
}}
>
Upload
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default UploadFileModal;

View file

@ -0,0 +1,45 @@
import React from 'react';
import FileSidePanel from './FileList/FileSidePanel';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
import FilesSectionSelector from './FilesSectionSelector';
import { Button } from '../ui';
export default function FilesListView() {
const params = useParams();
const navigate = useNavigate();
return (
<div className="bg-[#f9f9f9] p-0 lg:p-7">
<div className="m-4 flex w-full flex-row justify-between md:m-2">
<FilesSectionSelector />
{params?.fileId && (
<Button
className="block lg:hidden"
variant={'outline'}
size={'sm'}
onClick={() => {
navigate('/d/files');
}}
>
Go back
</Button>
)}
</div>
<div className="flex w-full flex-row divide-x">
<div
className={`mr-2 w-full xl:w-1/3 ${
params.fileId ? 'hidden w-1/2 lg:block lg:w-1/2' : 'md:w-full'
}`}
>
<FileSidePanel />
</div>
<div
className={`h-[85vh] w-full overflow-y-auto xl:w-2/3 ${
params.fileId ? 'lg:w-1/2' : 'hidden md:w-1/2 lg:block'
}`}
>
<Outlet />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { Button } from '../ui';
import { useLocation, useNavigate } from 'react-router-dom';
export default function FilesSectionSelector() {
const navigate = useNavigate();
const location = useLocation();
let selectedPage = '/vector-stores';
if (location.pathname.includes('vector-stores')) {
selectedPage = '/vector-stores';
}
if (location.pathname.includes('files')) {
selectedPage = '/files';
}
const darkButton = { backgroundColor: 'black', color: 'white' };
const lightButton = { backgroundColor: '#f9f9f9', color: 'black' };
return (
<div className="flex h-12 w-52 flex-row justify-center rounded border bg-white p-1">
<div className="flex w-2/3 items-center pr-1">
<Button
className="w-full rounded rounded-lg border"
style={selectedPage === '/vector-stores' ? darkButton : lightButton}
onClick={() => {
selectedPage = '/vector-stores';
navigate('/d/vector-stores');
}}
>
Vector Stores
</Button>
</div>
<div className="flex w-1/3 items-center">
<Button
className="w-full rounded rounded-lg border"
style={selectedPage === '/files' ? darkButton : lightButton}
onClick={() => {
selectedPage = '/files';
navigate('/d/files');
}}
>
Files
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
import React from 'react';
export default function EmptyVectorStorePreview() {
return (
<div className="h-full w-full content-center text-center font-bold">
Select a vector store to view details.
</div>
);
}

View file

@ -0,0 +1,18 @@
import { PlusIcon } from 'lucide-react';
import React from 'react';
import { Button } from '~/components/ui';
type VectorStoreButtonProps = {
onClick: () => void;
};
export default function VectorStoreButton({ onClick }: VectorStoreButtonProps) {
return (
<div className="w-full">
<Button className="w-full bg-black p-0 text-white" onClick={onClick}>
<PlusIcon className="h-4 w-4 font-bold" />
&nbsp; <span className="text-nowrap">Add Store</span>
</Button>
</div>
);
}

View file

@ -0,0 +1,7 @@
import React from 'react';
const VectorStoreFilter = () => {
return <div>VectorStoreFilter</div>;
};
export default VectorStoreFilter;

View file

@ -0,0 +1,22 @@
import React from 'react';
import VectorStoreListItem from './VectorStoreListItem';
import { TVectorStore } from '~/common';
type VectorStoreListProps = {
vectorStores: TVectorStore[];
deleteVectorStore: (id: string | undefined) => void;
};
export default function VectorStoreList({ vectorStores, deleteVectorStore }: VectorStoreListProps) {
return (
<div>
{vectorStores.map((vectorStore, index) => (
<VectorStoreListItem
key={index}
vectorStore={vectorStore}
deleteVectorStore={deleteVectorStore}
/>
))}
</div>
);
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { TVectorStore } from '~/common';
import { DotsIcon, NewTrashIcon, TrashIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type VectorStoreListItemProps = {
vectorStore: TVectorStore;
deleteVectorStore: (id: string) => void;
};
export default function VectorStoreListItem({
vectorStore,
deleteVectorStore,
}: VectorStoreListItemProps) {
const navigate = useNavigate();
return (
<div
onClick={() => {
navigate('vs_id_abcdef');
}}
className="w-100 mt-2 flex cursor-pointer flex-row justify-around rounded-md border border-0 bg-white p-4 transition duration-300 ease-in-out hover:bg-slate-200"
>
<div className="flex w-1/2 flex-col justify-around align-middle">
<strong>{vectorStore.name}</strong>
<p className="text-sm text-gray-500">{vectorStore.object}</p>
</div>
<div className="w-2/6 text-gray-500">
<p>
{vectorStore.file_counts.total} Files ({vectorStore.bytes / 1000}KB)
</p>
<p className="text-sm">{vectorStore.created_at.toString()}</p>
</div>
<div className="flex w-1/6 flex-col justify-around sm:flex-row">
<Button className="m-0 w-full content-center bg-transparent p-0 text-gray-500 hover:bg-slate-200 sm:w-min">
<DotsIcon className="text-grey-100 m-0 p-0" />
</Button>
<Button
className="m-0 w-full bg-transparent p-0 text-[#666666] hover:bg-slate-200 sm:w-fit"
onClick={() => deleteVectorStore(vectorStore._id)}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,244 @@
import React, { useState } from 'react';
import DeleteIconButton from '../DeleteIconButton';
import { Button } from '~/components/ui';
import { NewTrashIcon } from '~/components/svg';
import { TFile } from 'librechat-data-provider/dist/types';
import UploadFileButton from '../FileList/UploadFileButton';
import UploadFileModal from '../FileList/UploadFileModal';
import { BarChart4Icon, Clock3, FileClock, FileIcon, InfoIcon, PlusIcon } from 'lucide-react';
import { useParams } from 'react-router-dom';
const tempVectorStore = {
_id: 'vs_NeHK4JidLKJ2qo23dKLLK',
name: 'Vector Store 1',
usageThisMonth: '1,000,000',
bytes: 1000000,
lastActive: '2022-01-01T10:00:00',
expirationPolicy: 'Never',
expires: 'Never',
createdAt: '2022-01-01T10:00:00',
};
const tempFilesAttached: TFile[] = [
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
{
filename: 'File1.jpg',
object: 'file',
bytes: 10000,
createdAt: '2022-01-01T10:00:00',
_id: '1',
type: 'image',
usage: 12,
user: 'abc',
file_id: 'file_id',
embedded: true,
filepath: 'filepath',
},
];
const tempAssistants = [
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
{
id: 'Lorum Ipsum',
resource: 'Lorum Ipsum',
},
];
export default function VectorStorePreview() {
const [open, setOpen] = useState(false);
const [vectorStore, setVectorStore] = useState(tempVectorStore);
const [filesAttached, setFilesAttached] = useState(tempFilesAttached);
const [assistants, setAssistants] = useState(tempAssistants);
const params = useParams();
return (
<div className="m-3 ml-1 mr-7 bg-white p-2 sm:p-4 md:p-6 lg:p-10">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col">
<b className="hidden text-base md:text-lg lg:block lg:text-xl">VECTOR STORE</b>
<b className="text-center text-xl md:text-2xl lg:text-left lg:text-3xl">
{vectorStore.name}
</b>
</div>
<div className="mt-3 flex flex-row gap-x-3 md:mt-0">
<div>
<DeleteIconButton
onClick={() => {
console.log('click');
}}
/>
</div>
<div>
<UploadFileButton
onClick={() => {
setOpen(true);
}}
/>
</div>
</div>
</div>
<div className="mt-3 flex flex-col">
<div className="flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp; ID
</span>
<span className="w-1/2 break-words text-gray-500 md:w-3/5">{vectorStore._id}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<BarChart4Icon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Usage this &nbsp;month
</span>
<div className="w-1/2 md:w-3/5">
<p className="text-gray-500">
<span className="text-[#91c561]">0 KB hours</span>
&nbsp; Free until end of 2024
</p>
</div>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Size
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.bytes} bytes</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<Clock3 className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Last active
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.lastActive}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<InfoIcon className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Expiration policy
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.expirationPolicy}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<FileClock className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Expires
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.expires}</span>
</div>
<div className="mt-3 flex flex-row">
<span className="flex w-1/2 flex-row items-center md:w-2/5">
<Clock3 className="text-base text-gray-500 md:text-lg lg:text-xl" />
&nbsp;Created At
</span>
<span className="w-1/2 text-gray-500 md:w-3/5">{vectorStore.createdAt?.toString()}</span>
</div>
</div>
<div className="mt-10 flex flex-col">
<div>
<b className="text-base md:text-lg lg:text-xl">Files attached</b>
</div>
<div className="flex flex-col divide-y">
<div className="mt-2 flex flex-row">
<div className="w-1/2 text-base md:text-lg lg:w-2/3 lg:text-xl">File</div>
<div className="w-1/2 text-base md:text-lg lg:w-1/3 lg:text-xl">Uploaded</div>
</div>
<div>
{filesAttached.map((file, index) => (
<div key={index} className="my-2 flex h-5 flex-row">
<div className="lg:w flex w-1/2 flex-row content-center lg:w-2/3">
<FileIcon className="m-0 size-5 p-0" />
<div className="ml-2 content-center">{file.filename}</div>
</div>
<div className="flex w-1/2 flex-row lg:w-1/3">
<div className="content-center text-nowrap">{file.createdAt?.toString()}</div>
<Button
className="my-0 ml-3 h-min bg-transparent p-0 text-[#666666] hover:bg-slate-200"
onClick={() => console.log('click')}
>
<NewTrashIcon className="m-0 p-0" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div className="mt-10 flex flex-col">
<div className="flex flex-row justify-between">
<b className="text-base md:text-lg lg:text-xl">Used by</b>
<Button variant={'default'}>
<PlusIcon className="h-4 w-4 font-bold" />
&nbsp; Create Assistant
</Button>
</div>
<div className="flex flex-col divide-y">
<div className="mt-2 flex flex-row">
<div className="w-1/2 text-base md:text-lg lg:w-2/3 lg:text-xl">Resource</div>
<div className="w-1/2 text-base md:text-lg lg:w-1/3 lg:text-xl">ID</div>
</div>
<div>
{assistants.map((assistant, index) => (
<div key={index} className="flex flex-row">
<div className="w-1/2 content-center lg:w-2/3">{assistant.resource}</div>
<div className="flex w-1/2 flex-row lg:w-1/3">
<div className="content-center">{assistant.id}</div>
</div>
</div>
))}
</div>
</div>
</div>
{open && <UploadFileModal open={open} onOpenChange={setOpen} />}
</div>
);
}

View file

@ -0,0 +1,252 @@
import React from 'react';
import VectorStoreList from './VectorStoreList';
import { TVectorStore } from '~/common';
import VectorStoreButton from './VectorStoreButton';
import { Button, Input } from '~/components/ui';
import FilesSectionSelector from '../FilesSectionSelector';
import ActionButton from '../ActionButton';
import DeleteIconButton from '../DeleteIconButton';
import { ListFilter } from 'lucide-react';
import { useLocalize } from '~/hooks';
const fakeVectorStores: TVectorStore[] = [
{
name: 'VectorStore 1',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '1',
},
{
name: 'VectorStore 2',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '2',
},
{
name: 'VectorStore 3',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '3',
},
{
name: 'VectorStore 4',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '4',
},
{
name: 'VectorStore 5',
bytes: 10000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '5',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
{
name: 'VectorStore 6',
bytes: 2000,
file_counts: {
total: 10,
in_progress: 0,
completed: 0,
failed: 0,
cancelled: 0,
},
created_at: '2022-01-01T10:00:00',
object: 'vector_store',
_id: '6',
},
];
export default function VectorStoreSidePanel() {
const localize = useLocalize();
const deleteVectorStore = (id: string | undefined) => {
// Define delete functionality here
console.log(`Deleting VectorStore with id: ${id}`);
};
return (
<div className="flex flex-col">
<div className="m-3 flex max-h-[10vh] flex-col">
<h2 className="text-lg">
<strong>Vector Stores</strong>
</h2>
<div className="m-1 mt-2 flex w-full flex-row justify-between gap-x-2 lg:m-0">
<div className="flex w-2/3 flex-row">
<Button variant="ghost" className="m-0 mr-2 p-0">
<ListFilter className="h-4 w-4" />
</Button>
<Input
placeholder={localize('com_files_filter')}
value={''}
onChange={() => {
console.log('changed');
}}
className="max-w-sm dark:border-gray-500"
/>
</div>
<div className="w-1/3">
<VectorStoreButton
onClick={() => {
console.log('Add Vector Store');
}}
/>
</div>
</div>
</div>
<div className="mr-2 mt-2 max-h-[80vh] w-full overflow-y-auto">
<VectorStoreList vectorStores={fakeVectorStores} deleteVectorStore={deleteVectorStore} />
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import React from 'react';
import VectorStoreSidePanel from './VectorStore/VectorStoreSidePanel';
import FilesSectionSelector from './FilesSectionSelector';
import { Button } from '../ui';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
export default function VectorStoreView() {
const params = useParams();
const navigate = useNavigate();
return (
<div className="max-h-[100vh] bg-[#f9f9f9] p-0 lg:p-7">
<div className="m-4 flex max-h-[10vh] w-full flex-row justify-between md:m-2">
<FilesSectionSelector />
<Button
className="block lg:hidden"
variant={'outline'}
size={'sm'}
onClick={() => {
navigate('/d/vector-stores');
}}
>
Go back
</Button>
</div>
<div className="flex max-h-[90vh] w-full flex-row divide-x">
<div
className={`max-h-full w-full xl:w-1/3 ${
params.vectorStoreId ? 'hidden w-1/2 lg:block lg:w-1/2' : 'md:w-full'
}`}
>
<VectorStoreSidePanel />
</div>
<div
className={`max-h-full w-full overflow-y-auto xl:w-2/3 ${
params.vectorStoreId ? 'lg:w-1/2' : 'hidden md:w-1/2 lg:block'
}`}
>
<Outlet />
</div>
</div>
</div>
);
}

View file

@ -41,6 +41,7 @@ const EXPIRY = {
ONE_DAY: { display: 'in 1 day', value: 24 * 60 * 60 * 1000 },
ONE_WEEK: { display: 'in 7 days', value: 7 * 24 * 60 * 60 * 1000 },
ONE_MONTH: { display: 'in 30 days', value: 30 * 24 * 60 * 60 * 1000 },
NEVER: { display: 'never', value: 0 },
};
const SetKeyDialog = ({
@ -84,7 +85,13 @@ const SetKeyDialog = ({
const submit = () => {
const selectedOption = expirationOptions.find((option) => option.display === expiresAtLabel);
const expiresAt = Date.now() + (selectedOption ? selectedOption.value : 0);
let expiresAt;
if (selectedOption?.value === 0) {
expiresAt = null;
} else {
expiresAt = Date.now() + (selectedOption ? selectedOption.value : 0);
}
const saveKey = (key: string) => {
saveUserKey(key, expiresAt);
@ -160,18 +167,18 @@ const SetKeyDialog = ({
main={
<div className="grid w-full items-center gap-2">
<small className="text-red-600">
{`${localize('com_endpoint_config_key_encryption')} ${
!expiryTime
? localize('com_endpoint_config_key_expiry')
: `${new Date(expiryTime).toLocaleString()}`
}`}
</small>
{expiryTime === 'never'
? localize('com_endpoint_config_key_never_expires')
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
expiryTime,
).toLocaleString()}`}
</small>{' '}
<Dropdown
label="Expires "
value={expiresAtLabel}
onChange={handleExpirationChange}
options={expirationOptions.map((option) => option.display)}
width={185}
sizeClasses="w-[185px]"
/>
<FormProvider {...methods}>
<EndpointComponent

View file

@ -33,7 +33,7 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plug
const codeString = codeRef.current?.textContent;
if (codeString) {
setIsCopied(true);
copy(codeString);
copy(codeString, { format: 'text/plain' });
setTimeout(() => {
setIsCopied(false);

View file

@ -87,6 +87,7 @@ const EditMessage = ({
contentEditable={true}
ref={textEditor}
suppressContentEditableWarning={true}
dir="auto"
>
{text}
</div>

View file

@ -1,4 +1,4 @@
import { Disclosure } from '@headlessui/react';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
import { useCallback, memo, ReactNode } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TResPlugin, TInput } from 'librechat-data-provider';
@ -98,12 +98,12 @@ const Plugin: React.FC<PluginProps> = ({ plugin }) => {
</div>
</div>
{plugin.loading && <Spinner className="ml-1 text-black" />}
<Disclosure.Button className="ml-12 flex items-center gap-2">
<DisclosureButton className="ml-12 flex items-center gap-2">
<ChevronDownIcon {...iconProps} />
</Disclosure.Button>
</DisclosureButton>
</div>
<Disclosure.Panel className="mt-3 flex max-w-full flex-col gap-3">
<DisclosurePanel className="mt-3 flex max-w-full flex-col gap-3">
<CodeBlock
lang={latestPlugin ? `REQUEST TO ${latestPlugin?.toUpperCase()}` : 'REQUEST'}
codeChildren={formatInputs(plugin.inputs ?? [])}
@ -120,7 +120,7 @@ const Plugin: React.FC<PluginProps> = ({ plugin }) => {
classProp="max-h-[450px]"
/>
)}
</Disclosure.Panel>
</DisclosurePanel>
</>
);
}}

View file

@ -8,7 +8,7 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
return (
<button
onClick={scrollHandler}
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-750/90 dark:text-gray-200"
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-850/90 dark:text-gray-200"
>
<svg
width="24"

View file

@ -1,9 +1,9 @@
import { useState } from 'react';
import { Dialog } from '~/components/ui/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { ClearChatsButton } from './SettingsTabs/';
import { useClearConversationsMutation } from 'librechat-data-provider/react-query';
import { useLocalize, useConversation, useConversations } from '~/hooks';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { ClearChatsButton } from './SettingsTabs';
import { Dialog } from '~/components/ui';
const ClearConvos = ({ open, onOpenChange }) => {
const { newConversation } = useConversation();

View file

@ -0,0 +1,102 @@
import 'test/resizeObserver.mock';
import 'test/matchMedia.mock';
import 'test/localStorage.mock';
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { AuthContextProvider } from '~/hooks/AuthContext';
import { SearchContext } from '~/Providers';
import Nav from './Nav';
const renderNav = ({ search, navVisible, setNavVisible }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<RecoilRoot>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthContextProvider>
<SearchContext.Provider value={search}>
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
</SearchContext.Provider>
</AuthContextProvider>
</QueryClientProvider>
</BrowserRouter>
</RecoilRoot>,
);
};
const mockMatchMedia = (mediaQueryList?: string[]) => {
mediaQueryList = mediaQueryList || [];
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: mediaQueryList.includes(query),
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
};
describe('Nav', () => {
beforeEach(() => {
mockMatchMedia();
});
it('renders visible', () => {
const { getByTestId } = renderNav({
search: { data: [], pageNumber: 1 },
navVisible: true,
setNavVisible: jest.fn(),
});
expect(getByTestId('nav')).toBeVisible();
});
it('renders hidden', async () => {
const { getByTestId } = renderNav({
search: { data: [], pageNumber: 1 },
navVisible: false,
setNavVisible: jest.fn(),
});
expect(getByTestId('nav')).not.toBeVisible();
});
it('renders hidden when small screen is detected', async () => {
mockMatchMedia(['(max-width: 768px)']);
const navVisible = true;
const mockSetNavVisible = jest.fn();
const { getByTestId } = renderNav({
search: { data: [], pageNumber: 1 },
navVisible: navVisible,
setNavVisible: mockSetNavVisible,
});
// nav is initially visible
expect(getByTestId('nav')).toBeVisible();
// when small screen is detected, the nav is hidden
expect(mockSetNavVisible.mock.calls).toHaveLength(1);
const updatedNavVisible = mockSetNavVisible.mock.calls[0][0](navVisible);
expect(updatedNavVisible).not.toEqual(navVisible);
expect(updatedNavVisible).toBeFalsy();
});
});

View file

@ -42,6 +42,10 @@ const Nav = ({ navVisible, setNavVisible }) => {
useEffect(() => {
if (isSmallScreen) {
const savedNavVisible = localStorage.getItem('navVisible');
if (savedNavVisible === null) {
toggleNavVisible();
}
setNavWidth('320px');
} else {
setNavWidth('260px');
@ -102,8 +106,9 @@ const Nav = ({ navVisible, setNavVisible }) => {
<TooltipProvider delayDuration={250}>
<Tooltip>
<div
data-testid="nav"
className={
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-750 md:max-w-[260px]'
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-50 dark:bg-gray-850 md:max-w-[260px]'
}
style={{
width: navVisible ? navWidth : '0px',

View file

@ -1,7 +1,7 @@
import { FileText } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { Fragment, useState, memo } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { Menu, MenuItem, MenuButton, MenuItems, Transition } from '@headlessui/react';
import { useGetUserBalance, useGetStartupConfig } from 'librechat-data-provider/react-query';
import FilesView from '~/components/Chat/Input/Files/FilesView';
import { useAuthContext } from '~/hooks/AuthContext';
@ -32,7 +32,7 @@ function NavLinks() {
<Menu as="div" className="group relative">
{({ open }) => (
<>
<Menu.Button
<MenuButton
className={cn(
'group-ui-open:bg-gray-100 dark:group-ui-open:bg-gray-700 duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800',
open ? 'bg-gray-100 dark:bg-gray-800' : '',
@ -64,7 +64,7 @@ function NavLinks() {
>
{user?.name || user?.username || localize('com_nav_user')}
</div>
</Menu.Button>
</MenuButton>
<Transition
as={Fragment}
@ -75,7 +75,7 @@ function NavLinks() {
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-2 opacity-0"
>
<Menu.Items className="absolute bottom-full left-0 z-[100] mb-1 mt-1 w-full translate-y-0 overflow-hidden rounded-lg border border-gray-300 bg-white p-1.5 opacity-100 shadow-lg outline-none dark:border-gray-600 dark:bg-gray-700">
<MenuItems className="absolute bottom-full left-0 z-[100] mb-1 mt-1 w-full translate-y-0 overflow-hidden rounded-lg border border-gray-300 bg-white p-1.5 opacity-100 shadow-lg outline-none dark:border-gray-600 dark:bg-gray-700">
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="none">
{user?.email || localize('com_nav_user')}
</div>
@ -90,34 +90,34 @@ function NavLinks() {
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
</>
)}
<Menu.Item as="div">
<MenuItem as="div">
<NavLink
svg={() => <FileText className="icon-md" />}
text={localize('com_nav_my_files')}
clickHandler={() => setShowFiles(true)}
/>
</Menu.Item>
</MenuItem>
{startupConfig?.helpAndFaqURL !== '/' && (
<Menu.Item as="div">
<MenuItem as="div">
<NavLink
svg={() => <LinkIcon />}
text={localize('com_nav_help_faq')}
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
/>
</Menu.Item>
</MenuItem>
)}
<Menu.Item as="div">
<MenuItem as="div">
<NavLink
svg={() => <GearIcon className="icon-md" />}
text={localize('com_nav_settings')}
clickHandler={() => setShowSettings(true)}
/>
</Menu.Item>
</MenuItem>
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
<Menu.Item as="div">
<MenuItem as="div">
<Logout />
</Menu.Item>
</Menu.Items>
</MenuItem>
</MenuItems>
</Transition>
</>
)}

View file

@ -82,7 +82,7 @@ export default function NewChat({
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<div className="sticky left-0 right-0 top-0 z-20 bg-gray-50 pt-3.5 dark:bg-gray-750">
<div className="sticky left-0 right-0 top-0 z-20 bg-gray-50 pt-3.5 dark:bg-gray-850">
<div className="pb-0.5 last:pb-0" tabIndex={0} style={{ transform: 'none' }}>
<a
href="/"

View file

@ -58,7 +58,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
return (
<div
ref={ref}
className="relative mt-1 flex flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-750 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
className="relative mt-1 flex flex h-10 cursor-pointer items-center gap-3 rounded-lg border-white bg-gray-50 px-2 px-3 py-2 text-black transition-colors duration-200 focus-within:bg-gray-200 hover:bg-gray-200 dark:bg-gray-850 dark:text-white dark:focus-within:bg-gray-800 dark:hover:bg-gray-800"
>
{<Search className="absolute left-3 h-4 w-4" />}
<input
@ -72,6 +72,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
placeholder={localize('com_nav_search_placeholder')}
onKeyUp={handleKeyUp}
autoComplete="off"
dir="auto"
/>
<X
className={cn(

View file

@ -2,7 +2,14 @@ import * as Tabs from '@radix-ui/react-tabs';
import { MessageSquare } from 'lucide-react';
import { SettingsTabValues } from 'librechat-data-provider';
import type { TDialogProps } from '~/common';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import {
Button,
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
import { General, Messages, Speech, Beta, Data, Account } from './SettingsTabs';
import { useMediaQuery, useLocalize } from '~/hooks';
@ -13,130 +20,183 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
const localize = useLocalize();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'overflow-hidden shadow-2xl md:min-h-[373px] md:w-[680px]',
isSmallScreen ? 'top-5 -translate-y-0' : '',
)}
>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
{localize('com_nav_settings')}
</DialogTitle>
</DialogHeader>
<div className="max-h-[373px] overflow-auto px-6 md:min-h-[373px] md:w-[680px]">
<Tabs.Root
defaultValue={SettingsTabValues.GENERAL}
className="flex flex-col gap-10 md:flex-row"
orientation="horizontal"
<Transition appear show={open}>
<Dialog as="div" className="relative z-50 focus:outline-none" onClose={onOpenChange}>
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50 dark:bg-black/80" aria-hidden="true" />
</TransitionChild>
<TransitionChild
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className={cn(
'fixed inset-0 flex w-screen items-center justify-center p-4',
isSmallScreen ? '' : '',
)}
>
<Tabs.List
aria-label="Settings"
role="tablist"
aria-orientation="horizontal"
<DialogPanel
className={cn(
'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-wrap overflow-auto sm:max-w-none',
isSmallScreen ? 'flex-row rounded-lg bg-gray-200 p-1 dark:bg-gray-800' : '',
'overflow-hidden rounded-xl rounded-b-lg bg-white pb-6 shadow-2xl backdrop-blur-2xl animate-in dark:bg-gray-700 sm:rounded-lg md:min-h-[373px] md:w-[680px]',
)}
style={{ outline: 'none' }}
>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.GENERAL}
style={{ userSelect: 'none' }}
<DialogTitle
className="mb-3 flex items-center justify-between border-b border-black/10 p-6 pb-5 text-left dark:border-white/10"
as="div"
>
<GearIcon />
{localize('com_nav_setting_general')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.MESSAGES}
style={{ userSelect: 'none' }}
>
<MessageSquare className="icon-sm" />
{localize('com_endpoint_messages')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.BETA}
style={{ userSelect: 'none' }}
>
<ExperimentIcon />
{localize('com_nav_setting_beta')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.SPEECH}
style={{ userSelect: 'none' }}
>
<SpeechIcon className="icon-sm" />
{localize('com_nav_setting_speech')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.DATA}
style={{ userSelect: 'none' }}
>
<DataIcon />
{localize('com_nav_setting_data')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 flex-col items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.ACCOUNT}
style={{ userSelect: 'none' }}
>
<UserIcon />
{localize('com_nav_setting_account')}
</Tabs.Trigger>
</Tabs.List>
<div className="h-screen max-h-[373px] overflow-auto sm:w-full sm:max-w-none">
<General />
<Messages />
<Beta />
<Speech />
<Data />
<Account />
</div>
</Tabs.Root>
</div>
</DialogContent>
</Dialog>
<h2 className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
{localize('com_nav_settings')}
</h2>
<button
type="button"
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900 dark:data-[state=open]:bg-gray-800"
onClick={() => onOpenChange(false)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5 text-black dark:text-white"
>
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
</svg>
<span className="sr-only">Close</span>
</button>
</DialogTitle>
<div className="max-h-[373px] overflow-auto px-6 md:min-h-[373px] md:w-[680px]">
<Tabs.Root
defaultValue={SettingsTabValues.GENERAL}
className="flex flex-col gap-10 md:flex-row"
orientation="horizontal"
>
<Tabs.List
aria-label="Settings"
role="tablist"
aria-orientation="horizontal"
className={cn(
'min-w-auto max-w-auto -ml-[8px] flex flex-shrink-0 flex-col flex-nowrap overflow-auto sm:max-w-none',
isSmallScreen ? 'flex-row rounded-lg bg-gray-200 p-1 dark:bg-gray-800' : '',
)}
style={{ outline: 'none' }}
>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.GENERAL}
style={{ userSelect: 'none' }}
>
<GearIcon />
{localize('com_nav_setting_general')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.MESSAGES}
style={{ userSelect: 'none' }}
>
<MessageSquare className="icon-sm" />
{localize('com_endpoint_messages')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.BETA}
style={{ userSelect: 'none' }}
>
<ExperimentIcon />
{localize('com_nav_setting_beta')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.SPEECH}
style={{ userSelect: 'none' }}
>
<SpeechIcon className="icon-sm" />
{localize('com_nav_setting_speech')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.DATA}
style={{ userSelect: 'none' }}
>
<DataIcon />
{localize('com_nav_setting_data')}
</Tabs.Trigger>
<Tabs.Trigger
className={cn(
'group m-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-white radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-600',
isSmallScreen
? 'flex-1 items-center justify-center text-nowrap text-sm dark:text-gray-500 dark:radix-state-active:text-white'
: 'bg-white radix-state-active:bg-gray-200',
isSmallScreen ? '' : 'dark:bg-gray-700',
)}
value={SettingsTabValues.ACCOUNT}
style={{ userSelect: 'none' }}
>
<UserIcon />
{localize('com_nav_setting_account')}
</Tabs.Trigger>
</Tabs.List>
<div className="max-h-[373px] overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<General />
<Messages />
<Beta />
<Speech />
<Data />
<Account />
</div>
</Tabs.Root>
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
);
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import { useRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import HoverCardSettings from '../HoverCardSettings';
import DeleteAccount from './DeleteAccount';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
@ -25,7 +26,7 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
role="tabpanel"
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<Avatar />
</div>
@ -33,7 +34,10 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
<DeleteAccount />
</div>
<div className="flex items-center justify-between">
<div> {localize('com_nav_user_name_display')} </div>
<div className="flex items-center space-x-2">
<div>{localize('com_nav_user_name_display')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
</div>
<Switch
id="UsernameDisplay"
checked={UsernameDisplay}

View file

@ -1,21 +1,26 @@
import { FileImage } from 'lucide-react';
import React, { useState, useRef, useCallback } from 'react';
import { FileImage, RotateCw, Upload } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
import AvatarEditor from 'react-avatar-editor';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import type { TUser } from 'librechat-data-provider';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
import { Dialog, DialogContent, DialogHeader, DialogTitle, Slider } from '~/components/ui';
import { useUploadAvatarMutation, useGetFileConfig } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { cn, formatBytes } from '~/utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
import store from '~/store';
function Avatar() {
const setUser = useSetRecoilState(store.user);
const [input, setinput] = useState<File | null>(null);
const [image, setImage] = useState<string | File | null>(null);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [scale, setScale] = useState<number>(1);
const [rotation, setRotation] = useState<number>(0);
const editorRef = useRef<AvatarEditor | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
@ -27,7 +32,6 @@ function Avatar() {
onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') });
setDialogOpen(false);
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
},
onError: (error) => {
@ -36,107 +40,171 @@ function Avatar() {
},
});
useEffect(() => {
if (input) {
const reader = new FileReader();
reader.onloadend = () => {
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(input);
} else {
setPreviewUrl(null);
}
}, [input]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
handleFile(file);
};
const handleFile = (file: File | undefined) => {
if (fileConfig.avatarSizeLimit && file && file.size <= fileConfig.avatarSizeLimit) {
setinput(file);
setDialogOpen(true);
setImage(file);
setScale(1);
setRotation(0);
} else {
const megabytes = fileConfig.avatarSizeLimit ? formatBytes(fileConfig.avatarSizeLimit) : 2;
showToast({
message: localize('com_ui_upload_invalid'),
message: localize('com_ui_upload_invalid_var', megabytes + ''),
status: 'error',
});
}
};
const handleUpload = () => {
if (!input) {
console.error('No file selected');
return;
}
const formData = new FormData();
formData.append('input', input, input.name);
formData.append('manual', 'true');
uploadAvatar(formData);
const handleScaleChange = (value: number[]) => {
setScale(value[0]);
};
const handleRotate = () => {
setRotation((prev) => (prev + 90) % 360);
};
const handleUpload = () => {
if (editorRef.current) {
const canvas = editorRef.current.getImageScaledToCanvas();
canvas.toBlob((blob) => {
if (blob) {
const formData = new FormData();
formData.append('input', blob, 'avatar.png');
formData.append('manual', 'true');
uploadAvatar(formData);
}
}, 'image/png');
}
};
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFile(file);
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const openFileDialog = () => {
fileInputRef.current?.click();
};
const resetImage = useCallback(() => {
setImage(null);
setScale(1);
setRotation(0);
}, []);
return (
<>
<div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span>
<label
htmlFor={'file-upload-avatar'}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
>
<FileImage className="mr-1 flex w-[22px] items-center stroke-1" />
<button onClick={() => setDialogOpen(true)} className="btn btn-neutral relative">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_change_picture')}</span>
<input
id={'file-upload-avatar'}
value=""
type="file"
className={cn('hidden')}
accept=".png, .jpg"
onChange={handleFileChange}
/>
</label>
</button>
</div>
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
resetImage();
}
}}
>
<DialogContent
className={cn('shadow-2xl dark:bg-gray-700 dark:text-white md:h-[350px] md:w-[450px] ')}
className={cn('shadow-2xl dark:bg-gray-700 dark:text-white md:h-auto md:w-[450px]')}
style={{ borderRadius: '12px' }}
>
<DialogHeader>
<DialogTitle className="text-lg font-medium leading-6 text-gray-800 dark:text-gray-200">
{localize('com_ui_preview')}
{image ? localize('com_ui_preview') : localize('com_ui_upload_image')}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center">
{previewUrl && (
<img
src={previewUrl}
alt="Preview"
className="mb-2 rounded-full"
style={{
maxWidth: '100%',
maxHeight: '150px',
width: '150px',
height: '150px',
objectFit: 'cover',
}}
/>
)}
<button
className={cn(
'mt-4 rounded px-4 py-2 text-white transition-colors hover:bg-green-600 hover:text-gray-200',
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
)}
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? (
<div className="flex h-6">
<Spinner className="icon-sm m-auto" />
{image ? (
<>
<div className="relative overflow-hidden rounded-full">
<AvatarEditor
ref={editorRef}
image={image}
width={250}
height={250}
border={0}
borderRadius={125}
color={[255, 255, 255, 0.6]}
scale={scale}
rotate={rotation}
/>
</div>
) : (
localize('com_ui_upload')
)}
</button>
<div className="mt-4 flex w-full flex-col items-center space-y-4">
<div className="flex w-full items-center justify-center space-x-4">
<span className="text-sm">Zoom:</span>
<Slider
value={[scale]}
min={1}
max={5}
step={0.001}
onValueChange={handleScaleChange}
className="w-2/3 max-w-xs"
/>
</div>
<button
onClick={handleRotate}
className="rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500"
>
<RotateCw className="h-5 w-5" />
</button>
</div>
<button
className={cn(
'mt-4 flex items-center rounded px-4 py-2 text-white transition-colors hover:bg-green-600 hover:text-gray-200',
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
)}
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? (
<Spinner className="icon-sm mr-2" />
) : (
<Upload className="mr-2 h-5 w-5" />
)}
{localize('com_ui_upload')}
</button>
</>
) : (
<div
className="flex h-64 w-64 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-700"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<FileImage className="mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
{localize('com_ui_drag_drop')}
</p>
<button
onClick={openFileDialog}
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
>
{localize('com_ui_select_file')}
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>

View file

@ -1,17 +1,11 @@
import React, { useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogButton,
Input,
} from '~/components/ui';
import { Dialog, DialogContent, DialogHeader, DialogTitle, Input } from '~/components/ui';
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
import { useDeleteUserMutation } from '~/data-provider';
import { Spinner, LockIcon } from '~/components/svg';
import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks';
import DangerButton from '../DangerButton';
const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolean }) => {
const localize = useLocalize();
@ -50,16 +44,19 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
<div className="flex items-center justify-between">
<span>{localize('com_nav_delete_account')}</span>
<label>
<DialogButton
<DangerButton
id={'delete-user-account'}
disabled={disabled}
onClick={onClick}
actionTextCode="com_ui_delete"
className={cn(
'btn btn-danger relative border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800',
'btn relative border-none bg-red-500 text-white hover:bg-red-700 dark:hover:bg-red-700',
)}
>
{localize('com_ui_delete')}
</DialogButton>
confirmClear={false}
infoTextCode={''}
dataTestIdInitial={''}
dataTestIdConfirm={''}
/>
</label>
</div>
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>

View file

@ -11,7 +11,7 @@ function Beta() {
role="tabpanel"
className="w-full md:min-h-[271px]"
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ModularChat />
</div>

View file

@ -1,4 +1,5 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -20,7 +21,10 @@ export default function LaTeXParsingSwitch({
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_latex_parsing')} </div>
<div className="flex items-center space-x-2">
<div>{localize('com_nav_latex_parsing')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_latex_parsing" />
</div>
<Switch
id="LaTeXParsing"
checked={LaTeXParsing}

View file

@ -20,7 +20,7 @@ export default function ModularChatSwitch({
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_modular_chat')} </div>
<div> {localize('com_nav_modular_chat')} </div>
<Switch
id="modularChat"
checked={modularChat}

View file

@ -6,6 +6,7 @@ import { Spinner } from '~/components/svg';
import type { TDangerButtonProps } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import HoverCardSettings from './HoverCardSettings';
const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
const {
@ -20,6 +21,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
showText = true,
dataTestIdInitial,
dataTestIdConfirm,
infoDescriptionCode,
confirmActionTextCode = 'com_ui_confirm_action',
} = props;
const localize = useLocalize();
@ -33,14 +35,19 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
return (
<div className="flex items-center justify-between">
{showText && <div> {localize(infoTextCode)} </div>}
{showText && (
<div className={`flex items-center ${infoDescriptionCode ? 'space-x-2' : ''}`}>
<div>{localize(infoTextCode)}</div>
{infoDescriptionCode && <HoverCardSettings side="bottom" text={infoDescriptionCode} />}
</div>
)}
<DialogButton
id={id}
ref={ref}
disabled={disabled}
onClick={onClick}
className={cn(
' btn btn-danger relative min-w-[70px] border-none bg-red-700 text-white hover:bg-red-800 dark:hover:bg-red-800',
'btn relative border-none bg-red-500 text-white hover:bg-red-700 dark:hover:bg-red-700',
className,
)}
>

View file

@ -43,7 +43,7 @@ function Data() {
className="w-full md:min-h-[271px]"
ref={dataTabRef}
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ImportConversations />
</div>

View file

@ -45,6 +45,7 @@ export const DeleteCacheButton = ({
id={'delete-cache'}
actionTextCode={'com_ui_delete'}
infoTextCode={'com_nav_delete_cache_storage'}
infoDescriptionCode={'com_nav_info_delete_cache_storage'}
dataTestIdInitial={'delete-cache-initial'}
dataTestIdConfirm={'delete-cache-confirm'}
/>

View file

@ -68,11 +68,8 @@ function ImportConversations() {
return (
<div className="flex items-center justify-between">
<span>{localize('com_ui_import_conversation_info')}</span>
<label
htmlFor={'import-conversations-file'}
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-3 text-xs font-medium transition-colors hover:bg-gray-100 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-green-500"
>
<div>{localize('com_ui_import_conversation_info')}</div>
<label htmlFor={'import-conversations-file'} className="btn btn-neutral relative">
{allowImport ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : (

View file

@ -56,6 +56,7 @@ export const RevokeKeysButton = ({
id={'revoke-all-user-keys'}
actionTextCode={'com_ui_revoke'}
infoTextCode={'com_ui_revoke_info'}
infoDescriptionCode={'com_nav_info_revoke'}
dataTestIdInitial={'revoke-all-keys-initial'}
dataTestIdConfirm={'revoke-all-keys-confirm'}
mutation={all ? revokeKeysMutation : revokeKeyMutation}

View file

@ -1,8 +1,11 @@
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import { MessageSquare, Link as LinkIcon } from 'lucide-react';
import { useMemo, useState, MouseEvent } from 'react';
import { Link } from 'react-router-dom';
import { MessageSquare, Link as LinkIcon } from 'lucide-react';
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import {
Spinner,
@ -12,8 +15,6 @@ import {
TooltipTrigger,
TrashIcon,
} from '~/components';
import { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
import { Link } from 'react-router-dom';
function SharedLinkDeleteButton({
shareId,
@ -23,7 +24,17 @@ function SharedLinkDeleteButton({
setIsDeleting: (isDeleting: boolean) => void;
}) {
const localize = useLocalize();
const mutation = useDeleteSharedLinkMutation();
const { showToast } = useToastContext();
const mutation = useDeleteSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
setIsDeleting(false);
},
});
const handleDelete = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
@ -124,10 +135,8 @@ export default function ShareLinkTable({ className }: { className?: string }) {
const { isAuthenticated } = useAuthContext();
const [showLoading, setShowLoading] = useState(false);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSharedLinksInfiniteQuery(
{ pageNumber: '1', isPublic: true },
{ enabled: isAuthenticated },
);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading } =
useSharedLinksInfiniteQuery({ pageNumber: '1', isPublic: true }, { enabled: isAuthenticated });
const { containerRef } = useNavScrolling<SharedLinksResponse>({
setShowLoading,
@ -144,6 +153,17 @@ export default function ShareLinkTable({ className }: { className?: string }) {
classProp.className = className;
}
if (isLoading) {
return <Spinner className="m-1 mx-auto mb-4 h-4 w-4 text-black dark:text-white" />;
}
if (isError) {
return (
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
{localize('com_ui_share_retrieve_error')}
</div>
);
}
if (!sharedLinks || sharedLinks.length === 0) {
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
}

View file

@ -9,7 +9,7 @@ export default function SharedLinks() {
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_shared_links')} </div>
<div>{localize('com_nav_shared_links')}</div>
<Dialog>
<DialogTrigger asChild>

View file

@ -1,6 +1,6 @@
import { useLocalize } from '~/hooks';
import { Dialog, DialogTrigger } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { Dialog, DialogTrigger } from '~/components/ui';
import ArchivedChatsTable from './ArchivedChatsTable';
@ -9,8 +9,7 @@ export default function ArchivedChats() {
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_archived_chats')} </div>
<div>{localize('com_nav_archived_chats')}</div>
<Dialog>
<DialogTrigger asChild>
<button className="btn btn-neutral relative ">

View file

@ -28,14 +28,14 @@ export const ThemeSelector = ({
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_theme')} </div>
<div>{localize('com_nav_theme')}</div>
<Dropdown
value={theme}
onChange={onChange}
options={themeOptions}
width={220}
position={'left'}
maxHeight="200px"
sizeClasses="w-[220px]"
anchor="bottom start"
testId="theme-selector"
/>
</div>
@ -64,6 +64,7 @@ export const ClearChatsButton = ({
confirmActionTextCode="com_nav_confirm_clear"
dataTestIdInitial="clear-convos-initial"
dataTestIdConfirm="clear-convos-confirm"
infoDescriptionCode="com_nav_info_clear_all_chats"
onClick={onClick}
/>
);
@ -83,7 +84,7 @@ export const LangSelector = ({
{ value: 'auto', display: localize('com_nav_lang_auto') },
{ value: 'en-US', display: localize('com_nav_lang_english') },
{ value: 'zh-CN', display: localize('com_nav_lang_chinese') },
{ value: 'zh-TC', display: localize('com_nav_lang_traditionalchinese') },
{ value: 'zh-TW', display: localize('com_nav_lang_traditionalchinese') },
{ value: 'ar-EG', display: localize('com_nav_lang_arabic') },
{ value: 'de-DE', display: localize('com_nav_lang_german') },
{ value: 'es-ES', display: localize('com_nav_lang_spanish') },
@ -100,16 +101,18 @@ export const LangSelector = ({
{ value: 'nl-NL', display: localize('com_nav_lang_dutch') },
{ value: 'id-ID', display: localize('com_nav_lang_indonesia') },
{ value: 'he-HE', display: localize('com_nav_lang_hebrew') },
{ value: 'fi-FI', display: localize('com_nav_lang_finnish') },
];
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_language')} </div>
<div>{localize('com_nav_language')}</div>
<Dropdown
value={langcode}
onChange={onChange}
position={'left'}
maxHeight="271px"
sizeClasses="[--anchor-max-height:256px]"
anchor="bottom start"
options={languageOptions}
/>
</div>
@ -153,7 +156,7 @@ function General() {
className="w-full md:min-h-[271px]"
ref={contentRef}
>
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-50">
<div className="flex flex-col gap-3 text-sm text-black dark:text-gray-50">
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-600">
<ThemeSelector theme={theme} onChange={changeTheme} />
</div>

View file

@ -20,7 +20,8 @@ export default function HideSidePanelSwitch({
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_hide_panel')} </div>
<div>{localize('com_nav_hide_panel')}</div>
<Switch
id="hideSidePanel"
checked={hideSidePanel}

Some files were not shown because too many files have changed in this diff Show more