mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-15 15:08:52 +01:00
Merge branch 'main' into re-add-download-audio
This commit is contained in:
commit
32d84c85ea
408 changed files with 16931 additions and 4946 deletions
|
|
@ -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 }) => (
|
||||
|
|
|
|||
47
client/src/components/Chat/AddMultiConvo.tsx
Normal file
47
client/src/components/Chat/AddMultiConvo.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
66
client/src/components/Chat/Input/AddedConvo.tsx
Normal file
66
client/src/components/Chat/Input/AddedConvo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]" />
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
)}
|
||||
|
|
|
|||
231
client/src/components/Chat/Input/PromptsCommand.tsx
Normal file
231
client/src/components/Chat/Input/PromptsCommand.tsx
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
20
client/src/components/Chat/Input/TextareaHeader.tsx
Normal file
20
client/src/components/Chat/Input/TextareaHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
154
client/src/components/Chat/Messages/ui/MessageRender.tsx
Normal file
154
client/src/components/Chat/Messages/ui/MessageRender.tsx
Normal 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;
|
||||
10
client/src/components/Chat/Messages/ui/PlaceholderRow.tsx
Normal file
10
client/src/components/Chat/Messages/ui/PlaceholderRow.tsx
Normal 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;
|
||||
15
client/src/components/Chat/PromptCard.tsx
Normal file
15
client/src/components/Chat/PromptCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
client/src/components/Chat/PromptLanding.tsx
Normal file
62
client/src/components/Chat/PromptLanding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
client/src/components/Chat/Prompts.tsx
Normal file
95
client/src/components/Chat/Prompts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
client/src/components/Files/ActionButton.tsx
Normal file
20
client/src/components/Files/ActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
client/src/components/Files/DeleteIconButton.tsx
Normal file
17
client/src/components/Files/DeleteIconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
client/src/components/Files/FileDashboardView.tsx
Normal file
39
client/src/components/Files/FileDashboardView.tsx
Normal 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;
|
||||
277
client/src/components/Files/FileList/DataTableFile.tsx
Normal file
277
client/src/components/Files/FileList/DataTableFile.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
26
client/src/components/Files/FileList/FileList.tsx
Normal file
26
client/src/components/Files/FileList/FileList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
client/src/components/Files/FileList/FileListItem.tsx
Normal file
33
client/src/components/Files/FileList/FileListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
client/src/components/Files/FileList/FileListItem2.tsx
Normal file
76
client/src/components/Files/FileList/FileListItem2.tsx
Normal 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" />
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
180
client/src/components/Files/FileList/FilePreview.tsx
Normal file
180
client/src/components/Files/FileList/FilePreview.tsx
Normal 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" />
|
||||
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" />
|
||||
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" />
|
||||
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" />
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
187
client/src/components/Files/FileList/FileSidePanel.tsx
Normal file
187
client/src/components/Files/FileList/FileSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
client/src/components/Files/FileList/FileTableColumns.tsx
Normal file
126
client/src/components/Files/FileList/FileTableColumns.tsx
Normal 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" />
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
18
client/src/components/Files/FileList/UploadFileButton.tsx
Normal file
18
client/src/components/Files/FileList/UploadFileButton.tsx
Normal 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" />
|
||||
<span className="text-nowrap">Upload New File</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
client/src/components/Files/FileList/UploadFileModal.tsx
Normal file
88
client/src/components/Files/FileList/UploadFileModal.tsx
Normal 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;
|
||||
45
client/src/components/Files/FilesListView.tsx
Normal file
45
client/src/components/Files/FilesListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
client/src/components/Files/FilesSectionSelector.tsx
Normal file
48
client/src/components/Files/FilesSectionSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
<span className="text-nowrap">Add Store</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const VectorStoreFilter = () => {
|
||||
return <div>VectorStoreFilter</div>;
|
||||
};
|
||||
|
||||
export default VectorStoreFilter;
|
||||
22
client/src/components/Files/VectorStore/VectorStoreList.tsx
Normal file
22
client/src/components/Files/VectorStore/VectorStoreList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
244
client/src/components/Files/VectorStore/VectorStorePreview.tsx
Normal file
244
client/src/components/Files/VectorStore/VectorStorePreview.tsx
Normal 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" />
|
||||
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" />
|
||||
Usage this month
|
||||
</span>
|
||||
<div className="w-1/2 md:w-3/5">
|
||||
<p className="text-gray-500">
|
||||
<span className="text-[#91c561]">0 KB hours</span>
|
||||
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" />
|
||||
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" />
|
||||
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" />
|
||||
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" />
|
||||
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" />
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
252
client/src/components/Files/VectorStore/VectorStoreSidePanel.tsx
Normal file
252
client/src/components/Files/VectorStore/VectorStoreSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
client/src/components/Files/VectorStoreView.tsx
Normal file
43
client/src/components/Files/VectorStoreView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const EditMessage = ({
|
|||
contentEditable={true}
|
||||
ref={textEditor}
|
||||
suppressContentEditableWarning={true}
|
||||
dir="auto"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
102
client/src/components/Nav/Nav.spec.tsx
Normal file
102
client/src/components/Nav/Nav.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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="/"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue