WIP: Update UI to match Official Style; Vision and Assistants 👷🏽 (#1190)

* wip: initial client side code

* wip: initial api code

* refactor: export query keys from own module, export assistant hooks

* refactor(SelectDropDown): more customization via props

* feat: create Assistant and render real Assistants

* refactor: major refactor of UI components to allow multi-chat, working alongside CreationPanel

* refactor: move assistant routes to own directory

* fix(CreationHeader): state issue with assistant select

* refactor: style changes for form, fix setSiblingIdx from useChatHelpers to use latestMessageParentId, fix render issue with ChatView and change location

* feat: parseCompactConvo: begin refactor of slimmer JSON payloads between client/api

* refactor(endpoints): add assistant endpoint, also use EModelEndpoint as much as possible

* refactor(useGetConversationsQuery): use object to access query data easily

* fix(MultiMessage): react warning of bad state set, making use of effect during render (instead of useEffect)

* fix(useNewConvo): use correct atom key (index instead of convoId) for reset latestMessageFamily

* refactor: make routing navigation/conversation change simpler

* chore: add removeNullishValues for smaller payloads, remove unused fields, setup frontend pinging of assistant endpoint

* WIP: initial complete assistant run handling

* fix: CreationPanel form correctly setting internal state

* refactor(api/assistants/chat): revise functions to working run handling strategy

* refactor(UI): initial major refactor of ChatForm and options

* feat: textarea hook

* refactor: useAuthRedirect hook and change directory name

* feat: add ChatRoute (/c/), make optionsBar absolute and change on textarea height, add temp header

* feat: match new toggle Nav open button to ChatGPT's

* feat: add OpenAI custom classnames

* feat: useOriginNavigate

* feat: messages loading view

* fix: conversation navigation and effects

* refactor: make toggle change nav opacity

* WIP: new endpoint menu

* feat: NewEndpointsMenu complete

* fix: ensure set key dialog shows on endpoint change, and new conversation resets messages

* WIP: textarea styling fix, add temp footer, create basic file handling component

* feat: image file handling (UI)

* feat: PopOver and ModelSelect in Header, remove GenButtons

* feat: drop file handling

* refactor: bug fixes
use SSE at route level
add opts to useOriginNavigate
delay render of unfinishedMessage to avoid flickering
pass params (convoId) to chatHelpers to set messages query data based on param when the route is new (fixes can't continue convo on /new/)
style(MessagesView): matches height to official
fix(SSE): pass paramId and invalidate convos
style(Message): make bg uniform

* refactor(useSSE): setStorage within setConversation updates

* feat: conversationKeysAtom, allConversationsSelector, update convos query data on created message (if new), correctly handle convo deletion (individual)

* feat: add popover select dropdowns to allow options in header while allowing horizontal scroll for mobile

* style(pluginsSelect): styling changes

* refactor(NewEndpointsMenu): make UI components modular

* feat: Presets complete

* fix: preset editing, make by index

* fix: conversations not setting on inital navigation, fix getMessages() based on query param

* fix: changing preset no longer resets latestMessage

* feat: useOnClickOutside for OptionsPopover and fix bug that causes selection of preset when deleting

* fix: revert /chat/ switchToConvo, also use NewDeleteButton in Convo

* fix: Popover correctly closes on close Popover button using custom condition for useOnClickOutside

* style: new message and nav styling

* style: hover/sibling buttons and preset menu scrolling

* feat: new convo header button

* style(Textarea): minor style changes to textarea buttons

* feat: stop/continue generating and hide hoverbuttons when submitting

* feat: compact AI Provider schemas to make json payloads and db saves smaller

* style: styling changes for consistency on chat route

* fix: created usePresetIndexOptions to prevent bugs between /c/ and /chat/ routes when editing presets, removed redundant code from the new dialog

* chore: make /chat/ route default for now since we still lack full image support
This commit is contained in:
Danny Avila 2023-11-16 10:42:24 -05:00 committed by GitHub
parent adbeb46399
commit bac1fb67d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
171 changed files with 8380 additions and 468 deletions

View file

@ -0,0 +1,72 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { useGetMessagesByConvoId } from 'librechat-data-provider';
import { useChatHelpers, useDragHelpers, useSSE } from '~/hooks';
// import GenerationButtons from './Input/GenerationButtons';
import DragDropOverlay from './Input/Files/DragDropOverlay';
import MessagesView from './Messages/MessagesView';
// import OptionsBar from './Input/OptionsBar';
import { ChatContext } from '~/Providers';
import ChatForm from './Input/ChatForm';
import { Spinner } from '~/components';
import { buildTree } from '~/utils';
import Landing from './Landing';
import Header from './Header';
import Footer from './Footer';
import store from '~/store';
function ChatView({
// messagesTree,
// isLoading,
index = 0,
}: {
// messagesTree?: TMessage[] | null;
// isLoading: boolean;
index?: number;
}) {
const { conversationId } = useParams();
const submissionAtIndex = useRecoilValue(store.submissionByIndex(0));
useSSE(submissionAtIndex);
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
select: (data) => {
const dataTree = buildTree(data, false);
return dataTree?.length === 0 ? null : dataTree ?? null;
},
});
const chatHelpers = useChatHelpers(index, conversationId);
const { isOver, canDrop, drop } = useDragHelpers(chatHelpers.setFiles);
const isActive = canDrop && isOver;
return (
<ChatContext.Provider value={chatHelpers}>
<div
ref={drop}
className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800"
>
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
{isLoading && conversationId !== 'new' ? (
<div className="flex h-screen items-center justify-center">
<Spinner className="dark:text-white" />
</div>
) : messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing Header={<Header />} />
)}
{/* <OptionsBar messagesTree={messagesTree} /> */}
{/* <GenerationButtons endpoint={chatHelpers.conversation.endpoint ?? ''} /> */}
<div className="gizmo:border-t-0 gizmo:pl-0 gizmo:md:pl-0 w-full border-t pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-2 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />
</div>
{isActive && <DragDropOverlay />}
</div>
</div>
</div>
</ChatContext.Provider>
);
}
export default memo(ChatView);

View file

@ -0,0 +1,113 @@
// import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useListAssistantsQuery } from 'librechat-data-provider';
import type { Assistant } from 'librechat-data-provider';
import type { UseFormReset, UseFormSetValue } from 'react-hook-form';
import type { CreationForm, Actions, Option } from '~/common';
import SelectDropDown from '~/components/ui/SelectDropDown';
import { cn } from '~/utils/';
const keys = new Set(['name', 'id', 'description', 'instructions', 'model']);
type TAssistantOption = string | (Option & Assistant);
export default function CreationHeader({
reset,
value,
onChange,
setValue,
}: {
reset: UseFormReset<CreationForm>;
value: TAssistantOption;
onChange: (value: TAssistantOption) => void;
setValue: UseFormSetValue<CreationForm>;
}) {
const assistants = useListAssistantsQuery(
{
order: 'asc',
},
{
select: (res) =>
res.data.map((assistant) => ({
...assistant,
label: assistant?.name ?? '',
value: assistant.id,
})),
},
);
const onSelect = (value: string) => {
const assistant = assistants.data?.find((assistant) => assistant.id === value);
if (!assistant) {
reset();
return;
}
onChange({
...assistant,
label: assistant?.name ?? '',
value: assistant?.id ?? '',
});
const actions: Actions = {
function: false,
code_interpreter: false,
retrieval: false,
};
assistant?.tools
?.map((tool) => tool.type)
.forEach((tool) => {
actions[tool] = true;
});
Object.entries(assistant).forEach(([name, value]) => {
if (typeof value === 'number') {
return;
} else if (typeof value === 'object') {
return;
}
if (keys.has(name)) {
setValue(name as keyof CreationForm, value);
}
});
Object.entries(actions).forEach(([name, value]) => setValue(name as keyof Actions, value));
};
return (
<SelectDropDown
value={!value ? 'Create Assistant' : value}
setValue={onSelect}
availableValues={
assistants.data ?? [
{
label: 'Loading...',
value: '',
},
]
}
iconSide="left"
showAbove={false}
showLabel={false}
emptyTitle={true}
optionsClass="hover:bg-gray-20/50"
optionsListClass="rounded-lg shadow-lg"
currentValueClass={cn(
'text-md font-semibold text-gray-900 dark:text-gray-100',
value === '' ? 'text-gray-500' : '',
)}
className={cn(
'rounded-none',
'z-50 flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer hover:border-green-500 focus:border-green-500',
)}
renderOption={() => (
<span className="flex items-center gap-1.5 truncate">
<span className="absolute inset-y-0 left-0 flex items-center pl-2 text-gray-800 dark:text-gray-100">
<Plus className="w-[16px]" />
</span>
<span className={cn('ml-4 flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100')}>
{'Create Assistant'}
</span>
</span>
)}
/>
);
}

View file

@ -0,0 +1,228 @@
import { Controller, useWatch } from 'react-hook-form';
import type { Tool } from 'librechat-data-provider';
import type { CreationForm, Actions } from '~/common';
import { useCreateAssistantMutation, Tools, EModelEndpoint } from 'librechat-data-provider';
import { Separator } from '~/components/ui/Separator';
import { useAssistantsContext } from '~/Providers';
import { Switch } from '~/components/ui/Switch';
import CreationHeader from './CreationHeader';
import { useNewConvo } from '~/hooks';
export default function CreationPanel({ index = 0 }) {
const { switchToConversation } = useNewConvo(index);
const create = useCreateAssistantMutation();
const { control, handleSubmit, reset, setValue } = useAssistantsContext();
const onSubmit = (data: CreationForm) => {
const tools: Tool[] = [];
console.log(data);
if (data.function) {
tools.push({ type: Tools.function });
}
if (data.code_interpreter) {
tools.push({ type: Tools.code_interpreter });
}
if (data.retrieval) {
tools.push({ type: Tools.retrieval });
}
const {
name,
description,
instructions,
model,
// file_ids,
} = data;
create.mutate({
name,
description,
instructions,
model,
tools,
});
};
const assistant_id = useWatch({ control, name: 'id' });
// Render function for the Switch component
const renderSwitch = (name: keyof Actions) => (
<Controller
name={name}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative inline-flex h-6 w-11 items-center rounded-full data-[state=checked]:bg-green-500"
value={field?.value?.toString()}
/>
)}
/>
);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="h-auto w-1/3 flex-shrink-0 overflow-x-hidden"
>
<Controller
name="assistant"
control={control}
render={({ field }) => (
<CreationHeader
reset={reset}
value={field.value}
onChange={field.onChange}
setValue={setValue}
/>
)}
/>
<div className="h-auto bg-white px-8 pb-8 pt-6">
{/* Name */}
<div className="mb-4">
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="name">
Name
</label>
<Controller
name="name"
control={control}
render={({ field }) => (
<input
{...field}
value={field.value ?? ''}
{...{ max: 256 }}
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
id="name"
type="text"
placeholder="Optional: The name of the assistant"
/>
)}
/>
<Controller
name="id"
control={control}
render={({ field }) => (
<p className="h-3 text-xs italic text-gray-600">{field.value ?? ''}</p>
)}
/>
</div>
{/* Description */}
<div className="mb-4">
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="description">
Description
</label>
<Controller
name="description"
control={control}
render={({ field }) => (
<input
{...field}
value={field.value ?? ''}
{...{ max: 512 }}
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
id="description"
type="text"
placeholder="Optional: Describe your Assistant here"
/>
)}
/>
</div>
{/* Instructions */}
<div className="mb-6">
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="instructions">
Instructions
</label>
<Controller
name="instructions"
control={control}
render={({ field }) => (
<textarea
{...field}
value={field.value ?? ''}
{...{ max: 32768 }}
className="focus:shadow-outline w-full resize-none appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
id="instructions"
placeholder="The system instructions that the assistant uses"
rows={3}
/>
)}
/>
</div>
{/* Model */}
<div className="mb-6">
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="model">
Model
</label>
<Controller
name="model"
control={control}
render={({ field }) => (
<select
{...field}
className="focus:shadow-outline block w-full appearance-none rounded border border-gray-200 bg-white px-4 py-2 pr-8 text-sm leading-tight shadow hover:border-gray-100 focus:border-green-500 focus:outline-none focus:ring-0"
id="model"
>
<option value="gpt-3.5-turbo-1106">gpt-3.5-turbo-1106</option>
{/* Additional model options here */}
</select>
)}
/>
</div>
{/* Tools */}
<div className="mb-6">
<label className="mb-2 block text-xs font-bold text-gray-700">Tools</label>
<div className="flex flex-col space-y-4">
<Separator orientation="horizontal" className="bg-gray-100/50" />
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700">Functions</span>
{renderSwitch('function')}
</div>
<Separator orientation="horizontal" className="bg-gray-100/50" />
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700">Code Interpreter</span>
{renderSwitch('code_interpreter')}
</div>
<Separator orientation="horizontal" className="bg-gray-100/50" />
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-700">Retrieval</span>
{renderSwitch('retrieval')}
</div>
<Separator orientation="horizontal" className="bg-gray-100/50" />
</div>
</div>
<div className="flex items-center justify-end">
{/* Use Button */}
<button
className="focus:shadow-outline mx-2 rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
type="button"
onClick={(e) => {
e.preventDefault();
switchToConversation({
endpoint: EModelEndpoint.assistant,
conversationId: 'new',
assistant_id,
title: null,
createdAt: '',
updatedAt: '',
});
}}
>
Use
</button>
{/* Submit Button */}
<button
className="focus:shadow-outline rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
type="submit"
>
Save
</button>
</div>
</div>
</form>
);
}

View file

@ -0,0 +1,7 @@
export default function Footer() {
return (
<div className="relative px-2 py-2 text-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]">
<span>ChatGPT can make mistakes. Consider checking important information.</span>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { useOutletContext } from 'react-router-dom';
import type { ContextType } from '~/common';
import { EndpointsMenu, PresetsMenu, NewChat } from './Menus';
import HeaderOptions from './Input/HeaderOptions';
export default function Header() {
const { navVisible } = useOutletContext<ContextType>();
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white/95 p-2 font-semibold dark:bg-gray-800/90 dark:text-white">
<div className="flex items-center gap-2 overflow-x-auto">
{!navVisible && <NewChat />}
<EndpointsMenu />
<HeaderOptions />
<PresetsMenu />
</div>
{/* Empty div for spacing */}
<div />
</div>
);
}

View file

@ -0,0 +1,47 @@
import { useRecoilState } from 'recoil';
import type { ChangeEvent } from 'react';
import { useChatContext } from '~/Providers';
import AttachFile from './Files/AttachFile';
import StopButton from './StopButton';
import SendButton from './SendButton';
import Images from './Files/Images';
import Textarea from './Textarea';
import store from '~/store';
export default function ChatForm({ index = 0 }) {
const [text, setText] = useRecoilState(store.textByIndex(index));
const { ask, files, setFiles, conversation, isSubmitting, handleStopGenerating } =
useChatContext();
const submitMessage = () => {
ask({ text });
setText('');
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
submitMessage();
}}
className="stretch mx-2 flex flex-row gap-3 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl"
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
<div className="[&:has(textarea:focus)]:border-token-border-xheavy border-token-border-heavy shadow-xs dark:shadow-xs relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-black/10 bg-white shadow-[0_0_0_2px_rgba(255,255,255,0.95)] dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:shadow-[0_0_0_2px_rgba(52,53,65,0.95)] [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
<Images files={files} setFiles={setFiles} />
<Textarea
value={text}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
setText={setText}
submitMessage={submitMessage}
endpoint={conversation?.endpoint}
/>
<AttachFile endpoint={conversation?.endpoint ?? ''} />
{isSubmitting ? <StopButton stop={handleStopGenerating} /> : <SendButton text={text} />}
</div>
</div>
</div>
</form>
);
}

View file

@ -0,0 +1,24 @@
import type { EModelEndpoint } from 'librechat-data-provider';
import { AttachmentIcon } from '~/components/svg';
import { FileUpload } from '~/components/ui';
import { useFileHandling } from '~/hooks';
import { supportsFiles } from '~/common';
export default function AttachFile({ endpoint }: { endpoint: EModelEndpoint | '' }) {
const { handleFileChange } = useFileHandling();
if (!supportsFiles[endpoint]) {
return null;
}
return (
<div className="absolute bottom-1 left-0 md:left-1">
<FileUpload handleFileChange={handleFileChange} className="flex">
<button className="btn relative p-0 text-black dark:text-white" aria-label="Attach files">
<div className="flex w-full items-center justify-center gap-2">
<AttachmentIcon />
</div>
</button>
</FileUpload>
</div>
);
}

View file

@ -0,0 +1,56 @@
export default function DragDropOverlay() {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-gray-100 opacity-80 dark:bg-gray-800 dark:text-gray-100">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 132 108"
fill="none"
width="132"
height="108"
>
<g clipPath="url(#clip0_3605_64419)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.2025 29.3514C10.778 33.2165 8.51524 37.1357 11.8281 49.4995L13.4846 55.6814C16.7975 68.0453 20.7166 70.308 35.1411 66.443L43.3837 64.2344C57.8082 60.3694 60.0709 56.4502 56.758 44.0864L55.1016 37.9044C51.7887 25.5406 47.8695 23.2778 33.445 27.1428L29.3237 28.2471L25.2025 29.3514ZM18.1944 42.7244C18.8572 41.5764 20.325 41.1831 21.4729 41.8459L27.3517 45.24C28.4996 45.9027 28.8929 47.3706 28.2301 48.5185L24.836 54.3972C24.1733 55.5451 22.7054 55.9384 21.5575 55.2757C20.4096 54.613 20.0163 53.1451 20.6791 51.9972L22.8732 48.1969L19.0729 46.0028C17.925 45.3401 17.5317 43.8723 18.1944 42.7244ZM29.4091 56.3843C29.066 55.104 29.8258 53.7879 31.1062 53.4449L40.3791 50.9602C41.6594 50.6172 42.9754 51.377 43.3184 52.6573C43.6615 53.9376 42.9017 55.2536 41.6214 55.5967L32.3485 58.0813C31.0682 58.4244 29.7522 57.6646 29.4091 56.3843Z"
fill="#AFC1FF"
/>
</g>
<g clipPath="url(#clip1_3605_64419)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M86.8124 13.4036C81.0973 11.8722 78.5673 13.2649 77.0144 19.0603L68.7322 49.97C67.1793 55.7656 68.5935 58.2151 74.4696 59.7895L97.4908 65.958C103.367 67.5326 105.816 66.1184 107.406 60.1848L115.393 30.379C115.536 29.8456 115.217 29.2959 114.681 29.16C113.478 28.8544 112.435 28.6195 111.542 28.4183C106.243 27.2253 106.22 27.2201 109.449 20.7159C109.73 20.1507 109.426 19.4638 108.816 19.3004L86.8124 13.4036ZM87.2582 28.4311C86.234 28.1567 85.1812 28.7645 84.9067 29.7888C84.6323 30.813 85.2401 31.8658 86.2644 32.1403L101.101 36.1158C102.125 36.3902 103.178 35.7824 103.453 34.7581C103.727 33.7339 103.119 32.681 102.095 32.4066L87.2582 28.4311ZM82.9189 37.2074C83.1934 36.1831 84.2462 35.5753 85.2704 35.8497L100.107 39.8252C101.131 40.0996 101.739 41.1524 101.465 42.1767C101.19 43.201 100.137 43.8088 99.1132 43.5343L84.2766 39.5589C83.2523 39.2844 82.6445 38.2316 82.9189 37.2074ZM83.2826 43.2683C82.2584 42.9939 81.2056 43.6017 80.9311 44.626C80.6567 45.6502 81.2645 46.703 82.2888 46.9775L89.7071 48.9652C90.7313 49.2396 91.7841 48.6318 92.0586 47.6076C92.333 46.5833 91.7252 45.5305 90.7009 45.256L83.2826 43.2683Z"
fill="#7989FF"
/>
</g>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M40.4004 71.8426C40.4004 57.2141 44.0575 53.5569 61.1242 53.5569H66.0004H70.8766C87.9432 53.5569 91.6004 57.2141 91.6004 71.8426V79.1569C91.6004 93.7855 87.9432 97.4426 70.8766 97.4426H61.1242C44.0575 97.4426 40.4004 93.7855 40.4004 79.1569V71.8426ZM78.8002 67.4995C78.8002 70.1504 76.6512 72.2995 74.0002 72.2995C71.3492 72.2995 69.2002 70.1504 69.2002 67.4995C69.2002 64.8485 71.3492 62.6995 74.0002 62.6995C76.6512 62.6995 78.8002 64.8485 78.8002 67.4995ZM60.7204 70.8597C60.2672 70.2553 59.5559 69.8997 58.8004 69.8997C58.045 69.8997 57.3337 70.2553 56.8804 70.8597L47.2804 83.6597C46.4851 84.72 46.7 86.2244 47.7604 87.0197C48.8208 87.8149 50.3251 87.6 51.1204 86.5397L58.8004 76.2997L66.4804 86.5397C66.8979 87.0962 67.5363 87.4443 68.2303 87.4936C68.9243 87.5429 69.6055 87.2887 70.0975 86.7967L74.8004 82.0938L79.5034 86.7967C80.4406 87.734 81.9602 87.734 82.8975 86.7967C83.8347 85.8595 83.8347 84.3399 82.8975 83.4026L76.4975 77.0026C75.5602 76.0653 74.0406 76.0653 73.1034 77.0026L68.6601 81.4459L60.7204 70.8597Z"
fill="#3C46FF"
/>
<defs>
<clipPath id="clip0_3605_64419">
<rect
width="56"
height="56"
fill="white"
transform="translate(0 26.9939) rotate(-15)"
/>
</clipPath>
<clipPath id="clip1_3605_64419">
<rect
width="64"
height="64"
fill="white"
transform="translate(69.5645 0.5) rotate(15)"
/>
</clipPath>
</defs>
</svg>
<h3>Add anything</h3>
<h4 className="w-2/3">Drop any file here to add it to the conversation</h4>
</div>
);
}

View file

@ -0,0 +1,113 @@
type styleProps = {
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
backgroundRepeat?: string;
};
const Image = ({
imageBase64,
url,
onDelete,
progress = 1,
}: {
imageBase64?: string;
url?: string;
onDelete: () => void;
progress: number; // between 0 and 1
}) => {
let style: styleProps = {
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
if (imageBase64) {
style = {
...style,
backgroundImage: `url(${imageBase64})`,
};
} else if (url) {
style = {
...style,
backgroundImage: `url(${url})`,
};
}
if (!style.backgroundImage) {
return null;
}
const radius = 55; // Radius of the SVG circle
const circumference = 2 * Math.PI * radius;
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear',
};
return (
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
<div className="h-14 w-14">
<button
type="button"
aria-haspopup="dialog"
aria-expanded="false"
className="h-full w-full"
style={style}
/>
{progress < 1 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/5 text-white">
<svg width="120" height="120" viewBox="0 0 120 120" className="h-6 w-6">
<circle
className="origin-[50%_50%] -rotate-90 stroke-gray-400"
strokeWidth="10"
fill="transparent"
r="55"
cx="60"
cy="60"
/>
{/* <circle className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]" stroke="currentColor" strokeWidth="10" strokeDashoffset="311.01767270538954" strokeDasharray="345.57519189487726 345.57519189487726" fill="transparent" r="55" cx="60" cy="60"/>*/}
<circle
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
stroke="currentColor"
strokeWidth="10"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="transparent"
r="55"
cx="60"
cy="60"
style={circleCSSProperties}
/>
</svg>
</div>
)}
</div>
</div>
<button
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-white bg-gray-500 p-0.5 text-white transition-colors hover:bg-black hover:opacity-100 group-hover:opacity-100 md:opacity-0"
onClick={onDelete}
>
<span>
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="icon-sm"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</span>
</button>
</div>
);
};
export default Image;

View file

@ -0,0 +1,29 @@
import Image from './Image';
import { ExtendedFile } from '~/common';
export default function Images({
files,
setFiles,
}: {
files: ExtendedFile[];
setFiles: React.Dispatch<React.SetStateAction<ExtendedFile[]>>;
}) {
if (files.length === 0) {
return null;
}
return (
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
{files.map((file: ExtendedFile, index: number) => {
const handleDelete = () => {
setFiles((currentFiles) =>
currentFiles.filter((_file) => file.preview !== _file.preview),
);
};
return (
<Image key={index} url={file.preview} onDelete={handleDelete} progress={file.progress} />
);
})}
</div>
);
}

View file

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useMediaQuery, useGenerationsByLatest } from '~/hooks';
import Regenerate from '~/components/Input/Generations/Regenerate';
import Continue from '~/components/Input/Generations/Continue';
import Stop from '~/components/Input/Generations/Stop';
import { useChatContext } from '~/Providers';
import { cn } from '~/utils';
type GenerationButtonsProps = {
endpoint: string;
showPopover?: boolean;
opacityClass?: string;
};
export default function GenerationButtons({
endpoint,
showPopover = false,
opacityClass = 'full-opacity',
}: GenerationButtonsProps) {
const {
getMessages,
isSubmitting,
latestMessage,
handleContinue,
handleRegenerate,
handleStopGenerating,
} = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { continueSupported, regenerateEnabled } = useGenerationsByLatest({
endpoint,
message: latestMessage as TMessage,
isSubmitting,
latestMessage,
});
const [userStopped, setUserStopped] = useState(false);
const messages = getMessages();
const handleStop = (e: React.MouseEvent<HTMLButtonElement>) => {
setUserStopped(true);
handleStopGenerating(e);
};
useEffect(() => {
let timer: NodeJS.Timeout;
if (userStopped) {
timer = setTimeout(() => {
setUserStopped(false);
}, 200);
}
return () => {
clearTimeout(timer);
};
}, [userStopped]);
if (isSmallScreen) {
return null;
}
let button: React.ReactNode = null;
if (isSubmitting) {
button = <Stop onClick={handleStop} />;
} else if (userStopped || continueSupported) {
button = <Continue onClick={handleContinue} />;
} else if (messages && messages.length > 0 && regenerateEnabled) {
button = <Regenerate onClick={handleRegenerate} />;
}
return (
<div className="absolute bottom-0 right-0 z-[62]">
<div className="grow" />
<div className="flex items-center md:items-end">
<div
className={cn('option-buttons', showPopover ? '' : opacityClass)}
data-projection-id="173"
>
{button}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { Root, Anchor } from '@radix-ui/react-popover';
import { useState, useEffect, useMemo } from 'react';
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog } from '~/components/Endpoints';
import { ModelSelect } from '~/components/Input/ModelSelect';
import { PluginStoreDialog } from '~/components';
import OptionsPopover from './OptionsPopover';
import PopoverButtons from './PopoverButtons';
import { useSetIndexOptions } from '~/hooks';
import { useChatContext } from '~/Providers';
import { Button } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import store from '~/store';
export default function OptionsBar() {
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
);
const { showPopover, conversation, latestMessage, setShowPopover, setShowBingToneSetting } =
useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint, conversationId, jailbreak } = conversation ?? {};
const altConditions: { [key: string]: boolean } = {
bingAI: !!(latestMessage && conversation?.jailbreak && endpoint === 'bingAI'),
};
const altSettings: { [key: string]: () => void } = {
bingAI: () => setShowBingToneSetting((prev) => !prev),
};
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
[EModelEndpoint.chatGPTBrowser]: true,
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
}),
[jailbreak, conversationId],
);
useEffect(() => {
if (endpoint && noSettings[endpoint]) {
setShowPopover(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endpoint, noSettings]);
const saveAsPreset = () => {
setSaveAsDialogShow(true);
};
if (!endpoint) {
return null;
}
const triggerAdvancedMode = altConditions[endpoint]
? altSettings[endpoint]
: () => setShowPopover((prev) => !prev);
return (
<Root
open={showPopover}
// onOpenChange={} // called when the open state of the popover changes.
>
<Anchor>
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div className="z-[61] flex w-full items-center justify-center gap-2">
<ModelSelect
conversation={conversation}
setOption={setOption}
isMultiChat={true}
showAbove={false}
/>
{!noSettings[endpoint] && (
<Button
type="button"
className={cn(
cardStyle,
'min-w-4 z-50 flex h-[40px] flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-black/10 dark:radix-state-open:bg-black/20',
)}
onClick={triggerAdvancedMode}
>
<Settings2 className="w-4 text-gray-600 dark:text-white" />
</Button>
)}
</div>
<OptionsPopover
visible={showPopover}
saveAsPreset={saveAsPreset}
closePopover={() => setShowPopover(false)}
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
>
<div className="px-4 py-4">
<EndpointSettings
conversation={conversation}
setOption={setOption}
isMultiChat={true}
/>
</div>
</OptionsPopover>
<SaveAsPresetDialog
open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow}
preset={tPresetSchema.parse({ ...conversation })}
/>
<PluginStoreDialog
isOpen={showPluginStoreDialog}
setIsOpen={setShowPluginStoreDialog}
/>
</span>
</div>
</Anchor>
</Root>
);
}

View file

@ -0,0 +1,173 @@
import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
import { PluginStoreDialog } from '~/components';
import {
EndpointSettings,
SaveAsPresetDialog,
EndpointOptionsPopover,
} from '~/components/Endpoints';
import { ModelSelect } from '~/components/Input/ModelSelect';
import GenerationButtons from './GenerationButtons';
import PopoverButtons from './PopoverButtons';
import { useSetIndexOptions } from '~/hooks';
import { useChatContext } from '~/Providers';
import { Button } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import store from '~/store';
export default function OptionsBar({ messagesTree }) {
const [opacityClass, setOpacityClass] = useState('full-opacity');
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
);
const {
showPopover,
conversation,
latestMessage,
setShowPopover,
setShowBingToneSetting,
textareaHeight,
} = useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint, conversationId, jailbreak } = conversation ?? {};
const altConditions: { [key: string]: boolean } = {
bingAI: !!(latestMessage && conversation?.jailbreak && endpoint === 'bingAI'),
};
const altSettings: { [key: string]: () => void } = {
bingAI: () => setShowBingToneSetting((prev) => !prev),
};
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
[EModelEndpoint.chatGPTBrowser]: true,
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
}),
[jailbreak, conversationId],
);
useEffect(() => {
if (showPopover) {
return;
} else if (messagesTree && messagesTree.length >= 1) {
setOpacityClass('show');
} else {
setOpacityClass('full-opacity');
}
}, [messagesTree, showPopover]);
useEffect(() => {
if (endpoint && noSettings[endpoint]) {
setShowPopover(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endpoint, noSettings]);
const saveAsPreset = () => {
setSaveAsDialogShow(true);
};
if (!endpoint) {
return null;
}
const triggerAdvancedMode = altConditions[endpoint]
? altSettings[endpoint]
: () => setShowPopover((prev) => !prev);
return (
<div
className="absolute left-0 right-0 mx-auto mb-2 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl"
style={{
// TODO: option to hide footer and handle this
// bottom: `${80 + (textareaHeight - 56)}px`, // without footer
bottom: `${85 + (textareaHeight - 56)}px`, // with footer
}}
>
<GenerationButtons
endpoint={endpoint}
showPopover={showPopover}
opacityClass={opacityClass}
/>
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div
className={cn(
'options-bar z-[61] flex w-full flex-wrap items-center justify-center gap-2',
showPopover ? '' : opacityClass,
)}
onMouseEnter={() => {
if (showPopover) {
return;
}
setOpacityClass('full-opacity');
}}
onMouseLeave={() => {
if (showPopover) {
return;
}
if (!messagesTree || messagesTree.length === 0) {
return;
}
setOpacityClass('show');
}}
onFocus={() => {
if (showPopover) {
return;
}
setOpacityClass('full-opacity');
}}
onBlur={() => {
if (showPopover) {
return;
}
if (!messagesTree || messagesTree.length === 0) {
return;
}
setOpacityClass('show');
}}
>
<ModelSelect conversation={conversation} setOption={setOption} isMultiChat={true} />
{!noSettings[endpoint] && (
<Button
id="advanced-mode-button"
customId="advanced-mode-button"
type="button"
className={cn(
cardStyle,
'min-w-4 z-50 flex h-[40px] flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
)}
onClick={triggerAdvancedMode}
>
<Settings2 id="advanced-settings" className="w-4 text-gray-600 dark:text-white" />
</Button>
)}
</div>
<EndpointOptionsPopover
visible={showPopover}
saveAsPreset={saveAsPreset}
closePopover={() => setShowPopover(false)}
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
>
<div className="px-4 py-4">
<EndpointSettings
conversation={conversation}
setOption={setOption}
isMultiChat={true}
/>
</div>
</EndpointOptionsPopover>
<SaveAsPresetDialog
open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow}
preset={tPresetSchema.parse({ ...conversation })}
/>
<PluginStoreDialog isOpen={showPluginStoreDialog} setIsOpen={setShowPluginStoreDialog} />
</span>
</div>
);
}

View file

@ -0,0 +1,83 @@
import { useRef } from 'react';
import { Save } from 'lucide-react';
import { Portal, Content } from '@radix-ui/react-popover';
import type { ReactNode } from 'react';
import { useLocalize, useOnClickOutside } from '~/hooks';
import { cn, removeFocusOutlines } from '~/utils';
import { CrossIcon } from '~/components/svg';
import { Button } from '~/components/ui';
type TOptionsPopoverProps = {
children: ReactNode;
visible: boolean;
saveAsPreset: () => void;
closePopover: () => void;
PopoverButtons: ReactNode;
};
export default function OptionsPopover({
children,
// endpoint,
visible,
saveAsPreset,
closePopover,
PopoverButtons,
}: TOptionsPopoverProps) {
const popoverRef = useRef(null);
useOnClickOutside(
popoverRef,
() => closePopover(),
['dialog-template-content', 'shadcn-button', 'advanced-settings'],
(target) => {
const tagName = (target as Element)?.tagName;
return tagName === 'path' || tagName === 'svg' || tagName === 'circle';
},
);
const localize = useLocalize();
const cardStyle =
'shadow-xl rounded-md min-w-[75px] font-normal bg-white border-black/10 border dark:bg-gray-700 text-black dark:text-white';
if (!visible) {
return null;
}
return (
<Portal>
<Content sideOffset={8} align="start" ref={popoverRef} asChild>
<div className="z-0 flex w-full flex-col items-center md:px-4">
<div
className={cn(
cardStyle,
'dark:bg-gray-900',
'border-d-0 flex w-full flex-col overflow-hidden rounded-none border-s-0 border-t bg-white px-0 pb-[10px] dark:border-white/10 md:rounded-md md:border lg:w-[736px]',
)}
>
<div className="flex w-full items-center bg-slate-100 px-2 py-2 dark:bg-gray-800/60">
<Button
type="button"
className="h-auto justify-start bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-slate-200 hover:text-black focus:ring-0 dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white dark:focus:outline-none dark:focus:ring-offset-0"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />
{localize('com_endpoint_save_as_preset')}
</Button>
{PopoverButtons}
<Button
type="button"
className={cn(
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-medium font-normal text-black hover:bg-slate-200 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
removeFocusOutlines,
)}
onClick={closePopover}
>
<CrossIcon />
</Button>
</div>
<div>{children}</div>
</div>
</div>
</Content>
</Portal>
);
}

View file

@ -0,0 +1,74 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { MessagesSquared, GPTIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
import { Button } from '~/components';
import { cn } from '~/utils/';
type TPopoverButton = {
label: string;
buttonClass: string;
handler: () => void;
icon: ReactNode;
};
export default function PopoverButtons({
endpoint,
buttonClass,
iconClass = '',
}: {
endpoint: EModelEndpoint;
buttonClass?: string;
iconClass?: string;
}) {
const { optionSettings, setOptionSettings, showAgentSettings, setShowAgentSettings } =
useChatContext();
const { showExamples, isCodeChat } = optionSettings;
const triggerExamples = () =>
setOptionSettings((prev) => ({ ...prev, showExamples: !prev.showExamples }));
const buttons: { [key: string]: TPopoverButton[] } = {
[EModelEndpoint.google]: [
{
label: (showExamples ? 'Hide' : 'Show') + ' Examples',
buttonClass: isCodeChat ? 'disabled' : '',
handler: triggerExamples,
icon: <MessagesSquared className={cn('mr-1 w-[14px]', iconClass)} />,
},
],
[EModelEndpoint.gptPlugins]: [
{
label: `Show ${showAgentSettings ? 'Completion' : 'Agent'} Settings`,
buttonClass: '',
handler: () => setShowAgentSettings((prev) => !prev),
icon: <GPTIcon className={cn('mr-1 mt-[2px] w-[14px]', iconClass)} size={14} />,
},
],
};
const endpointButtons = buttons[endpoint];
if (!endpointButtons) {
return null;
}
return (
<div>
{endpointButtons.map((button, index) => (
<Button
key={`${endpoint}-button-${index}`}
type="button"
className={cn(
button.buttonClass,
'ml-1 h-auto justify-start bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-slate-200 hover:text-black focus:ring-0 focus:ring-offset-0 dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white dark:focus:outline-none dark:focus:ring-offset-0',
buttonClass ?? '',
)}
onClick={button.handler}
>
{button.icon}
{button.label}
</Button>
))}
</div>
);
}

View file

@ -0,0 +1,16 @@
import { SendIcon } from '~/components/svg';
export default function SendButton({ text }) {
return (
<button
disabled={!text}
className="enabled:bg-brand-purple absolute bottom-2.5 right-1.5 rounded-lg rounded-md border border-black p-0.5 p-1 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white md:bottom-3 md:right-3 md:p-[2px]"
data-testid="send-button"
type="submit"
>
<span className="" data-state="closed">
<SendIcon size={24} />
</span>
</button>
);
}

View file

@ -0,0 +1,30 @@
export default function StopButton({ stop }) {
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-950 rounded-full border-2 p-1 dark:border-gray-200"
aria-label="Stop generating"
onClick={stop}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="text-gizmo-gray-950 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>
);
}

View file

@ -0,0 +1,42 @@
import TextareaAutosize from 'react-textarea-autosize';
import { supportsFiles } from '~/common';
import { useTextarea } from '~/hooks';
export default function Textarea({ value, onChange, setText, submitMessage, endpoint }) {
const {
inputRef,
handleKeyDown,
handleKeyUp,
handleCompositionStart,
handleCompositionEnd,
onHeightChange,
placeholder,
} = useTextarea({ setText, submitMessage });
const className = supportsFiles[endpoint]
? 'm-0 w-full resize-none border-0 bg-transparent py-3.5 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent placeholder-black/50 dark:placeholder-white/50 pl-10 md:py-3.5 md:pr-12 md:pl-[55px]'
: 'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pr-12 gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 pl-3 md:pl-4';
return (
<TextareaAutosize
ref={inputRef}
autoFocus
value={value}
onChange={onChange}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onHeightChange={onHeightChange}
id="prompt-textarea"
tabIndex={0}
data-testid="text-input"
// style={{ maxHeight: '200px', height: '52px', overflowY: 'hidden' }}
rows={1}
placeholder={placeholder}
// className="m-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pr-12 gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 pl-12 gizmo:pl-10 md:pl-[46px] gizmo:md:pl-[55px]"
// className="gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 gizmo:pl-10 gizmo:md:pl-[55px] m-0 h-auto max-h-52 w-full resize-none overflow-y-hidden border-0 bg-transparent py-[10px] pl-12 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pl-[46px] md:pr-12"
className={className}
/>
);
}

View file

@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
export default function Landing({ Header }: { Header?: ReactNode }) {
return (
<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="mb-3 h-[72px] w-[72px]">
<div className="gizmo-shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
<svg
width="41"
height="41"
viewBox="0 0 41 41"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-2/3 w-2/3"
role="img"
>
<text x="-9999" y="-9999">
ChatGPT
</text>
<path
d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z"
fill="currentColor"
></path>
</svg>
</div>
</div>
<div className="mb-5 text-2xl font-medium dark:text-white">How can I help you today?</div>
</div>
</div>
);
}

View file

@ -0,0 +1,43 @@
import { EModelEndpoint } from 'librechat-data-provider';
import {
MinimalPlugin,
GPTIcon,
AnthropicIcon,
AzureMinimalIcon,
BingAIMinimalIcon,
PaLMinimalIcon,
LightningIcon,
} from '~/components/svg';
import { cn } from '~/utils';
export const icons = {
[EModelEndpoint.azureOpenAI]: AzureMinimalIcon,
[EModelEndpoint.openAI]: GPTIcon,
[EModelEndpoint.gptPlugins]: MinimalPlugin,
[EModelEndpoint.anthropic]: AnthropicIcon,
[EModelEndpoint.chatGPTBrowser]: LightningIcon,
[EModelEndpoint.google]: PaLMinimalIcon,
[EModelEndpoint.bingAI]: BingAIMinimalIcon,
[EModelEndpoint.assistant]: ({ className = '' }) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('icon-md shrink-0', className)}
>
<path
d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z"
fill="currentColor"
></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z"
fill="currentColor"
></path>
</svg>
),
unknown: GPTIcon,
};

View file

@ -0,0 +1,139 @@
import { useState } from 'react';
import { Settings } from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { FC } from 'react';
import { useLocalize, useUserKey, useNewConvo, useOriginNavigate } from '~/hooks';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { icons } from './Icons';
import { cn } from '~/utils';
type MenuItemProps = {
title: string;
value: EModelEndpoint;
selected: boolean;
description?: string;
userProvidesKey: boolean;
// iconPath: string;
// hoverContent?: string;
};
const MenuItem: FC<MenuItemProps> = ({
title,
value: endpoint,
description,
selected,
userProvidesKey,
...rest
}) => {
const Icon = icons[endpoint] ?? icons.unknown;
const [isDialogOpen, setDialogOpen] = useState(false);
const { getExpiry } = useUserKey(endpoint);
const { newConversation } = useNewConvo();
const navigate = useOriginNavigate();
const localize = useLocalize();
const expiryTime = getExpiry();
const onSelectEndpoint = (newEndpoint: EModelEndpoint) => {
if (!newEndpoint) {
return;
} else {
if (!expiryTime) {
setDialogOpen(true);
}
newConversation({ template: { endpoint: newEndpoint } });
navigate('new');
}
};
return (
<>
<div
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
tabIndex={-1}
{...rest}
onClick={() => onSelectEndpoint(endpoint)}
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className="flex items-center gap-2">
{<Icon size={18} className="icon-md shrink-0 dark:text-white" />}
{/* <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="icon-md shrink-0">
<path d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z" fill="currentColor"/>
<path fillRule="evenodd" clipRule="evenodd" d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z" fill="currentColor"/>
</svg> */}
<div>
{title}
<div className="text-token-text-tertiary">{description}</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{userProvidesKey ? (
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
<button
className={cn(
'invisible flex gap-x-1 group-hover:visible',
selected ? 'visible' : '',
expiryTime ? 'w-full rounded-lg p-2 hover:bg-gray-900' : '',
)}
onClick={(e) => {
e.preventDefault();
setDialogOpen(true);
}}
>
<div className={cn('invisible group-hover:visible', expiryTime ? 'text-xs' : '')}>
{localize('com_endpoint_config_key')}
</div>
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
</button>
</div>
) : null}
{selected && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block group-hover:hidden"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
)}
{(!userProvidesKey || expiryTime) && (
<div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
<div className="">New Chat</div>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
fill="currentColor"
/>
</svg>
</div>
)}
</div>
</div>
</div>
{userProvidesKey && (
<SetKeyDialog open={isDialogOpen} onOpenChange={setDialogOpen} endpoint={endpoint} />
)}
</>
);
};
export default MenuItem;

View file

@ -0,0 +1,41 @@
import type { FC } from 'react';
import { EModelEndpoint, useGetEndpointsQuery } from 'librechat-data-provider';
import MenuSeparator from '../UI/MenuSeparator';
import { alternateName } from '~/common';
import MenuItem from './MenuItem';
const EndpointItems: FC<{
endpoints: EModelEndpoint[];
selected: EModelEndpoint | '';
}> = ({ endpoints, selected }) => {
const { data: endpointsConfig } = useGetEndpointsQuery();
return (
<>
{endpoints &&
endpoints.map((endpoint, i) => {
if (!endpoint) {
return null;
} else if (!endpointsConfig?.[endpoint]) {
return null;
}
const userProvidesKey = endpointsConfig?.[endpoint]?.userProvide;
return (
<div key={`endpoint-${endpoint}`}>
<MenuItem
key={`endpoint-item-${endpoint}`}
title={alternateName[endpoint] || endpoint}
value={endpoint}
selected={selected === endpoint}
data-testid={`endpoint-item-${endpoint}`}
userProvidesKey={!!userProvidesKey}
// description="With DALL·E, browsing and analysis"
/>
{i !== endpoints.length - 1 && <MenuSeparator />}
</div>
);
})}
</>
);
};
export default EndpointItems;

View file

@ -0,0 +1,44 @@
import { Content, Portal, Root } from '@radix-ui/react-popover';
import { useGetEndpointsQuery } from 'librechat-data-provider';
import type { FC } from 'react';
import EndpointItems from './Endpoints/MenuItems';
import { useChatContext } from '~/Providers';
import TitleButton from './UI/TitleButton';
import { alternateName } from '~/common';
import { mapEndpoints } from '~/utils';
const EndpointsMenu: FC = () => {
const { data: endpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
const { conversation } = useChatContext();
const selected = conversation?.endpoint ?? '';
return (
<Root>
<TitleButton primaryText={alternateName[selected] + ' '} />
<Portal>
<div
style={{
position: 'fixed',
left: '0px',
top: '0px',
transform: 'translate3d(268px, 50px, 0px)',
minWidth: 'max-content',
zIndex: 'auto',
}}
>
<Content
side="bottom"
align="start"
className="mt-2 min-w-[340px] overflow-hidden rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
<EndpointItems endpoints={endpoints} selected={selected} />
</Content>
</div>
</Portal>
</Root>
);
};
export default EndpointsMenu;

View file

@ -0,0 +1,30 @@
import { useChatContext } from '~/Providers';
export default function Header() {
const { newConversation } = useChatContext();
return (
<button
type="button"
className=" btn btn-neutral btn-small border-token-border-medium relative ml-2 flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg rounded-lg border focus:ring-0 focus:ring-offset-0"
onClick={() => newConversation()}
>
<div className="flex w-full items-center justify-center gap-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-black dark:text-white"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
fill="currentColor"
/>
</svg>
</div>
</button>
);
}

View file

@ -0,0 +1,146 @@
import axios from 'axios';
import filenamify from 'filenamify';
import { useSetRecoilState } from 'recoil';
import exportFromJSON from 'export-from-json';
import { useGetEndpointsQuery } from 'librechat-data-provider';
import type { TEditPresetProps } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, cleanupPreset, mapEndpoints } from '~/utils';
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/';
import PopoverButtons from '~/components/Endpoints/PopoverButtons';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { EndpointSettings } from '~/components/Endpoints';
import { useChatContext } from '~/Providers';
import store from '~/store';
const EditPresetDialog = ({ open, onOpenChange, title }: Omit<TEditPresetProps, 'preset'>) => {
const { preset } = useChatContext();
// TODO: use React Query for presets data
const setPresets = useSetRecoilState(store.presets);
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
const { setOption } = useSetIndexOptions(preset);
const localize = useLocalize();
const submitPreset = () => {
if (!preset) {
return;
}
axios({
method: 'post',
url: '/api/presets',
data: cleanupPreset({ preset }),
withCredentials: true,
}).then((res) => {
setPresets(res?.data);
});
};
const exportPreset = () => {
if (!preset) {
return;
}
const fileName = filenamify(preset?.title || 'preset');
exportFromJSON({
data: cleanupPreset({ preset }),
fileName,
exportType: exportFromJSON.types.json,
});
};
const { endpoint } = preset || {};
if (!endpoint) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTemplate
title={`${title || localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${
preset?.title
}`}
className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[720px] md:w-[750px] md:overflow-y-hidden lg:w-[950px] xl:h-[720px]"
main={
<div className="flex w-full flex-col items-center gap-2 md:h-[530px]">
<div className="grid w-full grid-cols-5 gap-6">
<div className="col-span-4 flex items-start justify-start gap-4">
<div className="flex w-full flex-col">
<Label htmlFor="preset-name" className="mb-1 text-left text-sm font-medium">
{localize('com_endpoint_preset_name')}
</Label>
<Input
id="preset-name"
value={preset?.title || ''}
onChange={(e) => setOption('title')(e.target.value || '')}
placeholder={localize('com_endpoint_set_custom_name')}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
/>
</div>
<div className="flex w-full flex-col">
<Label htmlFor="endpoint" className="mb-1 text-left text-sm font-medium">
{localize('com_endpoint')}
</Label>
<Dropdown
value={endpoint || ''}
onChange={setOption('endpoint')}
options={availableEndpoints}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none ',
removeFocusOutlines,
)}
/>
</div>
</div>
<div className="col-span-2 flex items-start justify-start gap-4 sm:col-span-1">
<div className="flex w-full flex-col">
<Label
htmlFor="endpoint"
className="mb-1 hidden text-left text-sm font-medium sm:block"
>
{''}
</Label>
<PopoverButtons
endpoint={endpoint}
buttonClass="ml-0 w-full dark:bg-gray-700 dark:hover:bg-gray-800 p-2 h-[40px] justify-center mt-0"
iconClass="hidden lg:block w-4"
/>
</div>
</div>
</div>
<div className="my-4 w-full border-t border-gray-300 dark:border-gray-500" />
<div className="w-full p-0">
<EndpointSettings
conversation={preset}
setOption={setOption}
isPreset={true}
className="h-full md:mb-4 md:h-[440px]"
/>
</div>
</div>
}
buttons={
<div className="mb-6 md:mb-2">
<DialogButton onClick={exportPreset} className="dark:hover:gray-400 border-gray-700">
{localize('com_endpoint_export')}
</DialogButton>
<DialogClose
onClick={submitPreset}
className="dark:hover:gray-400 ml-2 border-gray-700 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
>
{localize('com_endpoint_save')}
</DialogClose>
</div>
}
/>
</Dialog>
);
};
export default EditPresetDialog;

View file

@ -0,0 +1,115 @@
import type { FC } from 'react';
import { Trash2 } from 'lucide-react';
import { Close } from '@radix-ui/react-popover';
import type { TPreset } from 'librechat-data-provider';
import FileUpload from '~/components/Input/EndpointMenu/FileUpload';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { Dialog, DialogTrigger } from '~/components/ui/';
import { EditIcon, TrashIcon } from '~/components/svg';
import { MenuSeparator, MenuItem } from '../UI';
import { icons } from '../Endpoints/Icons';
import { getPresetTitle } from '~/utils';
import { useLocalize } from '~/hooks';
const PresetItems: FC<{
presets: TPreset[];
onSelectPreset: (preset: TPreset) => void;
onChangePreset: (preset: TPreset) => void;
onDeletePreset: (preset: TPreset) => void;
clearAllPresets: () => void;
onFileSelected: (jsonData: Record<string, unknown>) => void;
}> = ({
presets,
onSelectPreset,
onChangePreset,
onDeletePreset,
clearAllPresets,
onFileSelected,
}) => {
const localize = useLocalize();
return (
<>
<div
role="menuitem"
className="pointer-none group m-1.5 flex h-8 min-w-[170px] gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5 md:min-w-[240px]"
tabIndex={-1}
>
<div className="flex h-full grow items-center justify-end gap-2">
<Dialog>
<DialogTrigger asChild>
<label
htmlFor="file-upload"
className="mr-1 flex h-[32px] h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors hover:bg-slate-200 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-500"
>
<Trash2 className="mr-1 flex w-[22px] items-center stroke-1" />
{localize('com_ui_clear')} {localize('com_ui_all')}
</label>
</DialogTrigger>
<DialogTemplate
title={`${localize('com_ui_clear')} ${localize('com_endpoint_presets')}`}
description={localize('com_endpoint_presets_clear_warning')}
selection={{
selectHandler: clearAllPresets,
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_clear'),
}}
className="max-w-[500px]"
/>
<FileUpload onFileSelected={onFileSelected} />
</Dialog>
</div>
</div>
{presets &&
presets.map((preset, i) => {
if (!preset) {
return null;
}
return (
<Close asChild key={`preset-${preset.presetId}`}>
<div key={`preset-${preset.presetId}`}>
<MenuItem
key={`preset-item-${preset.presetId}`}
className="w-[380px] md:min-w-[240px]"
textClassName="text-xs max-w-[180px] md:max-w-[250px]"
title={getPresetTitle(preset)}
disableHover={true}
onClick={() => onSelectPreset(preset)}
icon={icons[preset.endpoint ?? 'unknown']({ className: 'icon-md mr-1 ' })}
// value={preset.presetId}
selected={false}
data-testid={`preset-item-${preset}`}
// description="With DALL·E, browsing and analysis"
>
<div className="flex h-full items-center justify-end gap-1">
<button
className="m-0 h-full rounded-md px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:p-2 sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}}
>
<TrashIcon />
</button>
<button
className="m-0 h-full rounded-md px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:p-2 sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
onChangePreset(preset);
}}
>
<EditIcon />
</button>
</div>
</MenuItem>
{i !== presets.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
</>
);
};
export default PresetItems;

View file

@ -0,0 +1,2 @@
export { default as EditPresetDialog } from './EditPresetDialog';
export { default as PresetItems } from './PresetItems';

View file

@ -0,0 +1,130 @@
import type { FC } from 'react';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import {
modularEndpoints,
useDeletePresetMutation,
useCreatePresetMutation,
} from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider';
import { Content, Portal, Root } from '@radix-ui/react-popover';
import { useLocalize, useDefaultConvo, useNavigateToConvo } from '~/hooks';
import { EditPresetDialog, PresetItems } from './Presets';
import { useChatContext } from '~/Providers';
import TitleButton from './UI/TitleButton';
import { cleanupPreset } from '~/utils';
import store from '~/store';
const PresetsMenu: FC = () => {
const localize = useLocalize();
const { conversation, newConversation, setPreset } = useChatContext();
const { navigateToConvo } = useNavigateToConvo();
const getDefaultConversation = useDefaultConvo();
const [presetModalVisible, setPresetModalVisible] = useState(false);
// TODO: rely on react query for presets data
const [presets, setPresets] = useRecoilState(store.presets);
const deletePresetsMutation = useDeletePresetMutation();
const createPresetMutation = useCreatePresetMutation();
const { endpoint } = conversation ?? {};
const importPreset = (jsonPreset: TPreset) => {
createPresetMutation.mutate(
{ ...jsonPreset },
{
onSuccess: (data) => {
setPresets(data);
},
onError: (error) => {
console.error('Error uploading the preset:', error);
},
},
);
};
const onFileSelected = (jsonData: Record<string, unknown>) => {
const jsonPreset = { ...cleanupPreset({ preset: jsonData }), presetId: null };
importPreset(jsonPreset);
};
const onSelectPreset = (newPreset: TPreset) => {
if (!newPreset) {
return;
}
if (
modularEndpoints.has(endpoint ?? '') &&
modularEndpoints.has(newPreset?.endpoint ?? '') &&
endpoint === newPreset?.endpoint
) {
const currentConvo = getDefaultConversation({
conversation: conversation ?? {},
preset: newPreset,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
navigateToConvo(currentConvo, false);
return;
}
console.log('preset', newPreset, endpoint);
newConversation({ preset: newPreset });
};
const onChangePreset = (preset: TPreset) => {
setPreset(preset);
setPresetModalVisible(true);
};
const clearAllPresets = () => {
deletePresetsMutation.mutate({ arg: {} });
};
const onDeletePreset = (preset: TPreset) => {
deletePresetsMutation.mutate({ arg: preset });
};
return (
<Root>
<TitleButton primaryText={'Presets'} />
<Portal>
<div
style={{
position: 'fixed',
left: '0px',
top: '0px',
transform: 'translate3d(268px, 50px, 0px)',
minWidth: 'max-content',
zIndex: 'auto',
}}
>
<Content
side="bottom"
align="start"
className="mt-2 max-h-[495px] max-w-[370px] overflow-x-hidden rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white md:min-w-[400px]"
>
{presets.length ? (
<PresetItems
presets={presets}
onSelectPreset={onSelectPreset}
onChangePreset={onChangePreset}
onDeletePreset={onDeletePreset}
clearAllPresets={clearAllPresets}
onFileSelected={onFileSelected}
/>
) : (
<div className="dark:text-gray-300">{localize('com_endpoint_no_presets')}</div>
)}
</Content>
</div>
</Portal>
<EditPresetDialog
open={presetModalVisible}
onOpenChange={setPresetModalVisible}
// preset={preset as TPreset}
/>
</Root>
);
};
export default PresetsMenu;

View file

@ -0,0 +1,122 @@
import type { FC } from 'react';
import { cn } from '~/utils';
type MenuItemProps = {
title: string;
value?: string;
selected: boolean;
description?: string;
onClick?: () => void;
hoverCondition?: boolean;
hoverContent?: React.ReactNode;
children?: React.ReactNode;
icon?: React.ReactNode;
className?: string;
textClassName?: string;
disableHover?: boolean;
// hoverContent?: string;
};
const MenuItem: FC<MenuItemProps> = ({
title,
// value,
description,
selected,
// hoverCondition = true,
// hoverContent,
icon,
className = '',
textClassName = '',
disableHover = false,
children,
onClick,
}) => {
return (
<div
role="menuitem"
className={cn(
'group m-1.5 flex cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5 md:min-w-[240px]',
className ?? '',
)}
tabIndex={-1}
onClick={onClick}
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className={cn('flex items-center gap-1 ')}>
{icon && icon}
{/* <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="icon-md shrink-0">
<path d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z" fill="currentColor"/>
<path fillRule="evenodd" clipRule="evenodd" d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z" fill="currentColor"/>
</svg> */}
<div className={cn('truncate', textClassName)}>
{title}
<div className="text-token-text-tertiary">{description}</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{children}
{selected && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block "
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
)}
{!selected && !disableHover && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block hidden gap-x-1 group-hover:flex "
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
)}
{/* {(hoverCondition && hoverContent) && (
hoverContent
// <div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
// <div className="">New Chat</div>
// <svg
// width="24"
// height="24"
// viewBox="0 0 24 24"
// fill="none"
// xmlns="http://www.w3.org/2000/svg"
// className="icon-md"
// >
// <path
// fillRule="evenodd"
// clipRule="evenodd"
// d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
// fill="currentColor"
// />
// </svg>
// </div>
)
} */}
</div>
</div>
</div>
);
};
export default MenuItem;

View file

@ -0,0 +1,11 @@
import type { FC } from 'react';
const MenuSeparator: FC = () => (
<div
role="separator"
aria-orientation="horizontal"
className="my-1.5 border-b bg-gray-100 dark:border-gray-700"
/>
);
export default MenuSeparator;

View file

@ -0,0 +1,32 @@
import { Trigger } from '@radix-ui/react-popover';
export default function TitleButton({ primaryText = '', secondaryText = '' }) {
return (
<Trigger asChild>
<div
className="group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-black/10 dark:radix-state-open:bg-black/20"
// type="button"
>
<div>
{primaryText}{' '}
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
</div>
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
className="text-token-text-tertiary"
>
<path
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</Trigger>
);
}

View file

@ -0,0 +1,3 @@
export { default as MenuItem } from './MenuItem';
export { default as MenuSeparator } from './MenuSeparator';
export { default as TitleButton } from './TitleButton';

View file

@ -0,0 +1,3 @@
export { default as EndpointsMenu } from './EndpointsMenu';
export { default as PresetsMenu } from './PresetsMenu';
export { default as NewChat } from './NewChat';

View file

@ -0,0 +1,8 @@
// Container Component
const Container = ({ children }: { children: React.ReactNode }) => (
<div className="text-message peer flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto break-words peer-[.text-message]:mt-5">
{children}
</div>
);
export default Container;

View file

@ -0,0 +1,117 @@
import { useRef } from 'react';
import { useUpdateMessageMutation } from 'librechat-data-provider';
import Container from '~/components/Messages/Content/Container';
import { useChatContext } from '~/Providers';
import type { TEditProps } from '~/common';
import { useLocalize } from '~/hooks';
const EditMessage = ({
text,
message,
isSubmitting,
ask,
enterEdit,
siblingIdx,
setSiblingIdx,
}: TEditProps) => {
const { getMessages, setMessages, conversation } = useChatContext();
const textEditor = useRef<HTMLDivElement | null>(null);
const { conversationId, parentMessageId, messageId } = message;
const updateMessageMutation = useUpdateMessageMutation(conversationId ?? '');
const localize = useLocalize();
const resubmitMessage = () => {
const text = textEditor?.current?.innerText ?? '';
if (message.isCreatedByUser) {
ask({
text,
parentMessageId,
conversationId,
});
setSiblingIdx((siblingIdx ?? 0) - 1);
} else {
const messages = getMessages();
const parentMessage = messages?.find((msg) => msg.messageId === parentMessageId);
if (!parentMessage) {
return;
}
ask(
{ ...parentMessage },
{
editedText: text,
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
},
);
setSiblingIdx((siblingIdx ?? 0) - 1);
}
enterEdit(true);
};
const updateMessage = () => {
const messages = getMessages();
if (!messages) {
return;
}
const text = textEditor?.current?.innerText ?? '';
updateMessageMutation.mutate({
conversationId: conversationId ?? '',
model: conversation?.model ?? 'gpt-3.5-turbo',
messageId,
text,
});
setMessages(
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text,
isEdited: true,
}
: msg,
),
);
enterEdit(true);
};
return (
<Container>
<div
data-testid="message-text-editor"
className="markdown prose dark:prose-invert light w-full whitespace-pre-wrap break-words border-none focus:outline-none"
contentEditable={true}
ref={textEditor}
suppressContentEditableWarning={true}
>
{text}
</div>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={resubmitMessage}
>
{localize('com_ui_save')} {'&'} {localize('com_ui_submit')}
</button>
<button
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}
onClick={updateMessage}
>
{localize('com_ui_save')}
</button>
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
{localize('com_ui_cancel')}
</button>
</div>
</Container>
);
};
export default EditMessage;

View file

@ -0,0 +1,120 @@
import React, { useState, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider';
import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified';
import ReactMarkdown from 'react-markdown';
import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useChatContext } from '~/Providers';
import { langSubset, validateIframe } from '~/utils';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
type TCodeProps = {
inline: boolean;
className: string;
children: React.ReactNode;
};
type TContentProps = {
content: string;
message: TMessage;
showCursor?: boolean;
};
const code = React.memo(({ inline, className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1];
if (inline) {
return <code className={className}>{children}</code>;
} else {
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
}
});
const p = React.memo(({ children }: { children: React.ReactNode }) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
const Markdown = React.memo(({ content, message, showCursor }: TContentProps) => {
const [cursor, setCursor] = useState('█');
const { isSubmitting, latestMessage } = useChatContext();
const isInitializing = content === '<span className="result-streaming">█</span>';
const { isEdited, messageId } = message ?? {};
const isLatestMessage = messageId === latestMessage?.messageId;
const currentContent = content?.replace('z-index: 1;', '') ?? '';
useEffect(() => {
let timer1: NodeJS.Timeout, timer2: NodeJS.Timeout;
if (!showCursor) {
setCursor('');
return;
}
if (isSubmitting && isLatestMessage) {
timer1 = setInterval(() => {
setCursor('');
timer2 = setTimeout(() => {
setCursor('█');
}, 200);
}, 1000);
} else {
setCursor('');
}
// This is the cleanup function that React will run when the component unmounts
return () => {
clearInterval(timer1);
clearTimeout(timer2);
};
}, [isSubmitting, isLatestMessage, showCursor]);
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
[rehypeRaw],
];
let isValidIframe: string | boolean | null = false;
if (!isEdited) {
isValidIframe = validateIframe(currentContent);
}
if (isEdited || ((!isInitializing || !isLatestMessage) && !isValidIframe)) {
rehypePlugins.pop();
}
return (
<ReactMarkdown
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins}
linkTarget="_new"
components={
{
code,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
>
{isLatestMessage && isSubmitting && !isInitializing
? currentContent + cursor
: currentContent}
</ReactMarkdown>
);
});
export default Markdown;

View file

@ -0,0 +1,130 @@
import { Fragment, Suspense } from 'react';
import type { TResPlugin } from 'librechat-data-provider';
import type { TMessageContent, TText, TDisplayProps } from '~/common';
import Plugin from '~/components/Messages/Content/Plugin';
import Error from '~/components/Messages/Content/Error';
import { DelayedRender } from '~/components/ui';
import { useAuthContext } from '~/hooks';
import EditMessage from './EditMessage';
import Container from './Container';
import Markdown from './Markdown';
import { cn } from '~/utils';
const ErrorMessage = ({ text }: TText) => {
const { logout } = useAuthContext();
if (text.includes('ban')) {
logout();
return null;
}
return (
<Container>
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
<Error text={text} />
</div>
</Container>
);
};
// Display Message Component
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => (
<Container>
<div
className={cn(
'markdown prose dark:prose-invert light w-full break-words',
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
)}
>
{!isCreatedByUser ? (
<Markdown content={text} message={message} showCursor={showCursor} />
) : (
<>{text}</>
)}
</div>
</Container>
);
// Unfinished Message Component
const UnfinishedMessage = () => (
<ErrorMessage text="This is an unfinished message. The AI may still be generating a response, it was aborted, or a censor was triggered. Refresh or visit later to see more updates." />
);
// Content Component
const MessageContent = ({
text,
edit,
error,
unfinished,
isSubmitting,
isLast,
...props
}: TMessageContent) => {
if (error) {
return <ErrorMessage text={text} />;
} else if (edit) {
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
} else {
const marker = ':::plugin:::\n';
const splitText = text.split(marker);
const { message } = props;
const { plugins, messageId } = message;
const displayedIndices = new Set<number>();
// Function to get the next non-empty text index
const getNextNonEmptyTextIndex = (currentIndex: number) => {
for (let i = currentIndex + 1; i < splitText.length; i++) {
// Allow the last index to be last in case it has text
// this may need to change if I add back streaming
if (i === splitText.length - 1) {
return currentIndex;
}
if (splitText[i].trim() !== '' && !displayedIndices.has(i)) {
return i;
}
}
return currentIndex; // If no non-empty text is found, return the current index
};
return splitText.map((text, idx) => {
let currentText = text.trim();
let plugin: TResPlugin | null = null;
if (plugins) {
plugin = plugins[idx];
}
// If the current text is empty, get the next non-empty text index
const displayTextIndex = currentText === '' ? getNextNonEmptyTextIndex(idx) : idx;
currentText = splitText[displayTextIndex];
const isLastIndex = displayTextIndex === splitText.length - 1;
const isEmpty = currentText.trim() === '';
const showText =
(currentText && !isEmpty && !displayedIndices.has(displayTextIndex)) ||
(isEmpty && isLastIndex);
displayedIndices.add(displayTextIndex);
return (
<Fragment key={idx}>
{plugin && <Plugin key={`plugin-${messageId}-${idx}`} plugin={plugin} />}
{showText ? (
<DisplayMessage
key={`display-${messageId}-${idx}`}
showCursor={isLastIndex && isLast}
text={currentText}
{...props}
/>
) : null}
{!isSubmitting && unfinished && (
<Suspense>
<DelayedRender delay={250}>
<UnfinishedMessage key={`unfinished-${messageId}-${idx}`} />
</DelayedRender>
</Suspense>
)}
</Fragment>
);
});
}
};
export default MessageContent;

View file

@ -0,0 +1,104 @@
import { useState } from 'react';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerations, useLocalize } from '~/hooks';
import { cn } from '~/utils';
type THoverButtons = {
isEditing: boolean;
enterEdit: (cancel?: boolean) => void;
copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
conversation: TConversation | null;
isSubmitting: boolean;
message: TMessage;
regenerate: () => void;
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
latestMessage: TMessage | null;
};
export default function HoverButtons({
isEditing,
enterEdit,
copyToClipboard,
conversation,
isSubmitting,
message,
regenerate,
handleContinue,
latestMessage,
}: THoverButtons) {
const localize = useLocalize();
const { endpoint } = conversation ?? {};
const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
latestMessage,
});
if (!conversation) {
return null;
}
const { isCreatedByUser } = message;
const onEdit = () => {
if (isEditing) {
return enterEdit(true);
}
enterEdit();
};
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<button
className={cn(
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 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',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon />
</button>
<button
className={cn(
'hover-button ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 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',
isCreatedByUser ? '' : 'active',
)}
onClick={() => copyToClipboard(setIsCopied)}
type="button"
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark /> : <Clipboard />}
</button>
{regenerateEnabled ? (
<button
className="hover-button active rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 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"
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
{continueSupported ? (
<button
className="hover-button active rounded-md p-1 hover:bg-gray-100 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 "
onClick={handleContinue}
type="button"
title={localize('com_ui_continue')}
>
<ContinueIcon 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" />
</button>
) : null}
</div>
);
}

View file

@ -0,0 +1,200 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { Plugin } from '~/components/Messages/Content';
import MessageContent from './Content/MessageContent';
import { Icon } from '~/components/Endpoints';
import SiblingSwitch from './SiblingSwitch';
import type { TMessageProps } from '~/common';
import { useChatContext } from '~/Providers';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
// import { cn } from '~/utils';
export default function Message(props: TMessageProps) {
const {
message,
scrollToBottom,
currentEditId,
setCurrentEditId,
siblingIdx,
siblingCount,
setSiblingIdx,
} = props;
const {
ask,
regenerate,
autoScroll,
abortScroll,
isSubmitting,
conversation,
setAbortScroll,
handleContinue,
latestMessage,
setLatestMessage,
} = useChatContext();
const { conversationId } = conversation ?? {};
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
const isLast = !children?.length;
const edit = messageId === currentEditId;
useEffect(() => {
if (isSubmitting && scrollToBottom && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, text, scrollToBottom, abortScroll]);
useEffect(() => {
if (scrollToBottom && autoScroll && conversationId !== 'new') {
scrollToBottom();
}
}, [autoScroll, conversationId, scrollToBottom]);
useEffect(() => {
if (!message) {
return;
} else if (isLast) {
setLatestMessage({ ...message });
}
}, [isLast, message, setLatestMessage]);
if (!message) {
return null;
}
const enterEdit = (cancel?: boolean) =>
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
const handleScroll = () => {
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
};
// const commonClasses =
// 'w-full border-b text-gray-800 group border-black/10 dark:border-gray-900/50 dark:text-gray-100 dark:border-none';
// const uniqueClasses = isCreatedByUser
// ? 'bg-white dark:bg-gray-800 dark:text-gray-20'
// : 'bg-white dark:bg-gray-800 dark:text-gray-70';
// const messageProps = {
// className: cn(commonClasses, uniqueClasses),
// titleclass: '',
// };
const icon = Icon({
...conversation,
...message,
model: message?.model ?? conversation?.model,
size: 28.8,
});
const regenerateMessage = () => {
if (isSubmitting && isCreatedByUser) {
return;
}
regenerate(message);
};
const copyToClipboard = (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
setIsCopied(true);
copy(text ?? '');
setTimeout(() => {
setIsCopied(false);
}, 3000);
};
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 md:py-6">
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:gap-6 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="gizmo-shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
)}
</div>
</div>
</div>
</div>
<div className="agent-turn relative flex w-[calc(100%-50px)] w-full flex-col lg:w-[calc(100%-36px)]">
<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
isEditing={edit}
message={message}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
/>
</SubRow>
)}
</div>
</div>
</div>
</div>
<MultiMessage
key={messageId}
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}

View file

@ -0,0 +1,116 @@
import { useLayoutEffect, useState, useRef, useCallback } from 'react';
import type { ReactNode } from 'react';
import type { TMessage } from 'librechat-data-provider';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
import { CSSTransition } from 'react-transition-group';
import { useChatContext } from '~/Providers';
import MultiMessage from './MultiMessage';
import { useScrollToRef } from '~/hooks';
export default function MessagesView({
messagesTree: _messagesTree,
Header,
}: {
messagesTree?: TMessage[] | null;
Header?: ReactNode;
}) {
const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const { conversation, showPopover, setAbortScroll } = useChatContext();
const { conversationId } = conversation ?? {};
// TODO: screenshot target ref
// const { screenshotTargetRef } = useScreenshot();
const checkIfAtBottom = useCallback(() => {
if (!scrollableRef.current) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
const diff = Math.abs(scrollHeight - scrollTop);
const percent = Math.abs(clientHeight - diff) / clientHeight;
const hasScrollbar = scrollHeight > clientHeight && percent >= 0.15;
setShowScrollButton(hasScrollbar);
}, [scrollableRef]);
useLayoutEffect(() => {
const timeoutId = setTimeout(() => {
checkIfAtBottom();
}, 650);
// Add a listener on the window object
window.addEventListener('scroll', checkIfAtBottom);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('scroll', checkIfAtBottom);
};
}, [_messagesTree, checkIfAtBottom]);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debouncedHandleScroll = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(checkIfAtBottom, 100);
};
const scrollCallback = () => setShowScrollButton(false);
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
targetRef: messagesEndRef,
callback: scrollCallback,
smoothCallback: () => {
scrollCallback();
setAbortScroll(false);
},
});
return (
<div
className="flex-1 overflow-y-auto pt-0"
ref={scrollableRef}
onScroll={debouncedHandleScroll}
>
<div className="dark:gpt-dark-gray h-full">
<div>
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
{(_messagesTree && _messagesTree?.length == 0) || _messagesTree === null ? (
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-900/50 dark:bg-gray-800 dark:text-gray-300">
Nothing found
</div>
) : (
<>
{Header && Header}
<MultiMessage
key={conversationId} // avoid internal state mixture
messageId={conversationId ?? null}
messagesTree={_messagesTree}
scrollToBottom={scrollToBottom}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>
<CSSTransition
in={showScrollButton}
timeout={400}
classNames="scroll-down"
unmountOnExit={false}
// appear
>
{() =>
showScrollButton &&
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
}
</CSSTransition>
</>
)}
<div
className="dark:gpt-dark-gray group h-0 w-full flex-shrink-0 dark:border-gray-900/50"
ref={messagesEndRef}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
import store from '~/store';
export default function MultiMessage({
// messageId is used recursively here
messageId,
messagesTree,
scrollToBottom,
currentEditId,
setCurrentEditId,
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = (value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
};
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
setSiblingIdx(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesTree?.length]);
useEffect(() => {
if (messagesTree?.length && siblingIdx >= messagesTree?.length) {
setSiblingIdx(0);
}
}, [siblingIdx, messagesTree?.length, setSiblingIdx]);
if (!(messagesTree && messagesTree?.length)) {
return null;
}
const message = messagesTree[messagesTree.length - siblingIdx - 1];
if (!message) {
return null;
}
return (
<Message
key={message.messageId}
message={message}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
/>
);
}

View file

@ -0,0 +1,71 @@
import type { TMessageProps } from '~/common';
type TSiblingSwitchProps = Pick<TMessageProps, 'siblingIdx' | 'siblingCount' | 'setSiblingIdx'>;
export default function SiblingSwitch({
siblingIdx,
siblingCount,
setSiblingIdx,
}: TSiblingSwitchProps) {
if (siblingIdx === undefined) {
return null;
} else if (siblingCount === undefined) {
return null;
}
const previous = () => {
setSiblingIdx && setSiblingIdx(siblingIdx - 1);
};
const next = () => {
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
};
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"
onClick={previous}
disabled={siblingIdx == 0}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span className="flex-shrink-0 flex-grow tabular-nums">
{siblingIdx + 1}/{siblingCount}
</span>
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
onClick={next}
disabled={siblingIdx == siblingCount - 1}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
) : null;
}

View file

@ -0,0 +1,19 @@
import { cn } from '~/utils';
type TSubRowProps = {
children: React.ReactNode;
classes?: string;
subclasses?: string;
onClick?: () => void;
};
export default function SubRow({ children, classes = '', onClick }: TSubRowProps) {
return (
<div
className={cn('mt-1 flex justify-start gap-3 empty:hidden lg:flex', classes)}
onClick={onClick}
>
{children}
</div>
);
}

View file

@ -0,0 +1,41 @@
import { memo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import MessagesView from './Messages/MessagesView';
import OptionsBar from './Input/OptionsBar';
import CreationPanel from './CreationPanel';
import { ChatContext } from '~/Providers';
import { useChatHelpers } from '~/hooks';
import ChatForm from './Input/ChatForm';
import Landing from './Landing';
import Header from './Header';
function ChatView({
messagesTree,
index = 0,
}: {
messagesTree?: TMessage[] | null;
index?: number;
}) {
return (
<ChatContext.Provider value={useChatHelpers(index)}>
<div className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
<CreationPanel index={index} />
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-10 dark:bg-gray-800 md:pt-0">
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
{messagesTree && messagesTree.length !== 0 ? (
<MessagesView messagesTree={messagesTree} Header={<Header />} />
) : (
<Landing />
)}
<OptionsBar messagesTree={messagesTree} />
<div className="gizmo:border-t-0 gizmo:pl-0 gizmo:md:pl-0 w-full border-t pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-2 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
</div>
</div>
</div>
</div>
</ChatContext.Provider>
);
}
export default memo(ChatView);

View file

@ -111,7 +111,7 @@ export default function Conversation({ conversation, retainView }) {
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-800 hover:pr-4';
'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-900 hover:pr-4';
}
return (
@ -149,7 +149,7 @@ export default function Conversation({ conversation, retainView }) {
/>
</div>
) : (
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-gray-900 group-hover:from-gray-700/70" />
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-black group-hover:from-gray-900" />
)}
</a>
);

View file

@ -1,4 +1,6 @@
import Convo from './Convo';
import Conversation from './Conversation';
import { useLocation } from 'react-router-dom';
import { TConversation } from 'librechat-data-provider';
export default function Conversations({
@ -8,13 +10,22 @@ export default function Conversations({
conversations: TConversation[];
moveToTop: () => void;
}) {
const location = useLocation();
const { pathname } = location;
const ConvoItem = pathname.includes('chat') ? Conversation : Convo;
return (
<>
{conversations &&
conversations.length > 0 &&
conversations.map((convo: TConversation) => {
conversations.map((convo: TConversation, i) => {
return (
<Conversation key={convo.conversationId} conversation={convo} retainView={moveToTop} />
<ConvoItem
key={convo.conversationId}
conversation={convo}
retainView={moveToTop}
i={i}
/>
);
})}
</>

View file

@ -0,0 +1,142 @@
import { useRecoilValue } from 'recoil';
import { useState, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useUpdateConversationMutation } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import { useConversations, useNavigateToConvo } from '~/hooks';
import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import DeleteButton from './NewDeleteButton';
import RenameButton from './RenameButton';
import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLInputElement>;
export default function Conversation({ conversation, retainView, i }) {
const { conversationId: currentConvoId } = useParams();
const activeConvos = useRecoilValue(store.allConversationsSelector);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const { refreshConversations } = useConversations();
const { navigateToConvo } = useNavigateToConvo();
const { showToast } = useToastContext();
const { conversationId, title } = conversation;
const inputRef = useRef<HTMLInputElement | null>(null);
const [titleInput, setTitleInput] = useState(title);
const [renaming, setRenaming] = useState(false);
const clickHandler = async () => {
if (currentConvoId === conversationId) {
return;
}
// set document title
document.title = title;
// set conversation to the new conversation
if (conversation?.endpoint === 'gptPlugins') {
const lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools') ?? '') || [];
navigateToConvo({ ...conversation, tools: lastSelectedTools });
} else {
navigateToConvo(conversation);
}
};
const renameHandler = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setTitleInput(title);
setRenaming(true);
setTimeout(() => {
if (!inputRef.current) {
return;
}
inputRef.current.focus();
}, 25);
};
const onRename = (e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
e.preventDefault();
setRenaming(false);
if (titleInput === title) {
return;
}
updateConvoMutation.mutate(
{ conversationId, title: titleInput },
{
onSuccess: () => refreshConversations(),
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',
isCreatedByUser: false,
});
const handleKeyDown = (e: KeyEvent) => {
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-900 py-3 px-3 pr-14 hover:bg-gray-900',
};
const activeConvo =
currentConvoId === conversationId ||
(i === 0 && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new');
if (!activeConvo) {
aProps.className =
'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-900 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>
{activeConvo ? (
<div className="visible absolute right-1 z-10 flex text-gray-400">
<RenameButton renaming={renaming} onRename={onRename} renameHandler={renameHandler} />
<DeleteButton
conversationId={conversationId}
retainView={retainView}
renaming={renaming}
title={title}
/>
</div>
) : (
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-black group-hover:from-gray-900" />
)}
</a>
);
}

View file

@ -1,17 +1,15 @@
import TrashIcon from '../svg/TrashIcon';
import CrossIcon from '../svg/CrossIcon';
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { useDeleteConversationMutation } from 'librechat-data-provider';
import { Dialog, DialogTrigger, Label } from '~/components/ui/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { useLocalize, useConversations, useConversation } from '~/hooks';
import store from '~/store';
import { Dialog, DialogTrigger, Label } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { TrashIcon, CrossIcon } from '~/components/svg';
export default function DeleteButton({ conversationId, renaming, retainView, title }) {
const localize = useLocalize();
const currentConversation = useRecoilValue(store.conversation) || {};
const { newConversation } = useConversation();
const { refreshConversations } = useConversations();
const { conversationId: currentConvoId } = useParams();
const deleteConvoMutation = useDeleteConversationMutation(conversationId);
const confirmDelete = () => {
@ -19,9 +17,7 @@ export default function DeleteButton({ conversationId, renaming, retainView, tit
{ conversationId, source: 'button' },
{
onSuccess: () => {
if (
(currentConversation as { conversationId?: string }).conversationId == conversationId
) {
if (currentConvoId == conversationId) {
newConversation();
}

View file

@ -0,0 +1,59 @@
import { useParams } from 'react-router-dom';
import { useDeleteConversationMutation } from 'librechat-data-provider';
import { useLocalize, useConversations, useNewConvo } from '~/hooks';
import { Dialog, DialogTrigger, Label } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { TrashIcon, CrossIcon } from '~/components/svg';
export default function DeleteButton({ conversationId, renaming, retainView, title }) {
const localize = useLocalize();
// TODO: useNewConvo uses indices so we need to update global index state on every switch to Convo
const { newConversation } = useNewConvo();
const { refreshConversations } = useConversations();
const { conversationId: currentConvoId } = useParams();
const deleteConvoMutation = useDeleteConversationMutation(conversationId);
const confirmDelete = () => {
deleteConvoMutation.mutate(
{ conversationId, source: 'button' },
{
onSuccess: () => {
if (currentConvoId == conversationId) {
newConversation();
}
refreshConversations();
retainView();
},
},
);
};
return (
<Dialog>
<DialogTrigger asChild>
<button className="p-1 hover:text-white">{renaming ? <CrossIcon /> : <TrashIcon />}</button>
</DialogTrigger>
<DialogTemplate
title={localize('com_ui_delete_conversation')}
className="max-w-[450px]"
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">
{localize('com_ui_delete_conversation_confirm')} <strong>{title}</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: confirmDelete,
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</Dialog>
);
}

View file

@ -1,11 +1,10 @@
import React, { ReactElement } from 'react';
import RenameIcon from '../svg/RenameIcon';
import CheckMark from '../svg/CheckMark';
import type { MouseEvent, ReactElement } from 'react';
import { RenameIcon, CheckMark } from '~/components/svg';
interface RenameButtonProps {
renaming: boolean;
renameHandler: () => void;
onRename: () => void;
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
twcss?: string;
}

View file

@ -1,26 +1,28 @@
import React from 'react';
import { Save } from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import { Button } from '~/components/ui';
import { CrossIcon } from '~/components/svg';
import PopoverButtons from './PopoverButtons';
import type { ReactNode } from 'react';
// import { EModelEndpoint } from 'librechat-data-provider';
import { cn, removeFocusOutlines } from '~/utils';
// import PopoverButtons from './PopoverButtons';
import { CrossIcon } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
type TEndpointOptionsPopoverProps = {
children: React.ReactNode;
children: ReactNode;
visible: boolean;
endpoint: EModelEndpoint;
// endpoint: EModelEndpoint;
saveAsPreset: () => void;
closePopover: () => void;
PopoverButtons: ReactNode;
};
export default function EndpointOptionsPopover({
children,
endpoint,
// endpoint,
visible,
saveAsPreset,
closePopover,
PopoverButtons,
}: TEndpointOptionsPopoverProps) {
const localize = useLocalize();
const cardStyle =
@ -49,7 +51,7 @@ export default function EndpointOptionsPopover({
<Save className="mr-1 w-[14px]" />
{localize('com_endpoint_save_as_preset')}
</Button>
<PopoverButtons endpoint={endpoint} />
{PopoverButtons}
<Button
type="button"
className={cn(

View file

@ -1,36 +1,25 @@
import { useRecoilValue } from 'recoil';
import { OpenAISettings, BingAISettings, AnthropicSettings } from './Settings';
import { GoogleSettings, PluginsSettings } from './Settings/MultiView';
import type { TSettingsProps, TModelSelectProps, TBaseSettingsProps, TModels } from '~/common';
import type { TSettingsProps } from '~/common';
import { getSettings } from './Settings';
import { cn } from '~/utils';
import store from '~/store';
const optionComponents: { [key: string]: React.FC<TModelSelectProps> } = {
openAI: OpenAISettings,
azureOpenAI: OpenAISettings,
bingAI: BingAISettings,
anthropic: AnthropicSettings,
};
const multiViewComponents: { [key: string]: React.FC<TBaseSettingsProps & TModels> } = {
google: GoogleSettings,
gptPlugins: PluginsSettings,
};
export default function Settings({
conversation,
setOption,
isPreset = false,
className = '',
}: TSettingsProps) {
isMultiChat = false,
}: TSettingsProps & { isMultiChat?: boolean }) {
const modelsConfig = useRecoilValue(store.modelsConfig);
if (!conversation?.endpoint) {
return null;
}
const { settings, multiViewSettings } = getSettings(isMultiChat);
const { endpoint } = conversation;
const models = modelsConfig?.[endpoint] ?? [];
const OptionComponent = optionComponents[endpoint];
const OptionComponent = settings[endpoint];
if (OptionComponent) {
return (
@ -45,7 +34,7 @@ export default function Settings({
);
}
const MultiViewComponent = multiViewComponents[endpoint];
const MultiViewComponent = multiViewSettings[endpoint];
if (!MultiViewComponent) {
return null;

View file

@ -1,8 +1,8 @@
import React from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { Plugin, GPTIcon, AnthropicIcon, AzureMinimalIcon } from '~/components/svg';
import { useAuthContext } from '~/hooks';
import { cn } from '~/utils';
import { IconProps } from '~/common';
import { cn } from '~/utils';
const Icon: React.FC<IconProps> = (props) => {
const { size = 30, isCreatedByUser, button, model = true, endpoint, error, jailbreak } = props;
@ -19,7 +19,7 @@ const Icon: React.FC<IconProps> = (props) => {
width: size,
height: size,
}}
className={`relative flex items-center justify-center ${props.className ?? ''}`}
className={cn('relative flex items-center justify-center', props.className ?? '')}
>
<img
className="rounded-sm"
@ -33,12 +33,12 @@ const Icon: React.FC<IconProps> = (props) => {
);
} else {
const endpointIcons = {
azureOpenAI: {
[EModelEndpoint.azureOpenAI]: {
icon: <AzureMinimalIcon size={size * 0.5555555555555556} />,
bg: 'linear-gradient(0.375turn, #61bde2, #4389d0)',
name: 'ChatGPT',
},
openAI: {
[EModelEndpoint.openAI]: {
icon: <GPTIcon size={size * 0.5555555555555556} />,
bg:
typeof model === 'string' && model.toLowerCase().includes('gpt-4')
@ -46,18 +46,21 @@ const Icon: React.FC<IconProps> = (props) => {
: '#19C37D',
name: 'ChatGPT',
},
gptPlugins: {
[EModelEndpoint.gptPlugins]: {
icon: <Plugin size={size * 0.7} />,
bg: `rgba(69, 89, 164, ${button ? 0.75 : 1})`,
name: 'Plugins',
},
google: { icon: <img src="/assets/google-palm.svg" alt="Palm Icon" />, name: 'PaLM2' },
anthropic: {
[EModelEndpoint.google]: {
icon: <img src="/assets/google-palm.svg" alt="Palm Icon" />,
name: 'PaLM2',
},
[EModelEndpoint.anthropic]: {
icon: <AnthropicIcon size={size * 0.5555555555555556} />,
bg: '#d09a74',
name: 'Claude',
},
bingAI: {
[EModelEndpoint.bingAI]: {
icon: jailbreak ? (
<img src="/assets/bingai-jb.png" alt="Bing Icon" />
) : (
@ -65,7 +68,7 @@ const Icon: React.FC<IconProps> = (props) => {
),
name: jailbreak ? 'Sydney' : 'BingAI',
},
chatGPTBrowser: {
[EModelEndpoint.chatGPTBrowser]: {
icon: <GPTIcon size={size * 0.5555555555555556} />,
bg:
typeof model === 'string' && model.toLowerCase().includes('gpt-4')

View file

@ -1,4 +1,4 @@
import React from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import {
AzureMinimalIcon,
OpenAIMinimalIcon,
@ -21,13 +21,19 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
}
const endpointIcons = {
azureOpenAI: { icon: <AzureMinimalIcon />, name: props.chatGptLabel || 'ChatGPT' },
openAI: { icon: <OpenAIMinimalIcon />, name: props.chatGptLabel || 'ChatGPT' },
gptPlugins: { icon: <PluginMinimalIcon />, name: 'Plugins' },
google: { icon: <PaLMinimalIcon />, name: props.modelLabel || 'PaLM2' },
anthropic: { icon: <AnthropicMinimalIcon />, name: props.modelLabel || 'Claude' },
bingAI: { icon: <BingAIMinimalIcon />, name: 'BingAI' },
chatGPTBrowser: { icon: <ChatGPTMinimalIcon />, name: 'ChatGPT' },
[EModelEndpoint.azureOpenAI]: {
icon: <AzureMinimalIcon />,
name: props.chatGptLabel || 'ChatGPT',
},
[EModelEndpoint.openAI]: { icon: <OpenAIMinimalIcon />, name: props.chatGptLabel || 'ChatGPT' },
[EModelEndpoint.gptPlugins]: { icon: <PluginMinimalIcon />, name: 'Plugins' },
[EModelEndpoint.google]: { icon: <PaLMinimalIcon />, name: props.modelLabel || 'PaLM2' },
[EModelEndpoint.anthropic]: {
icon: <AnthropicMinimalIcon />,
name: props.modelLabel || 'Claude',
},
[EModelEndpoint.bingAI]: { icon: <BingAIMinimalIcon />, name: 'BingAI' },
[EModelEndpoint.chatGPTBrowser]: { icon: <ChatGPTMinimalIcon />, name: 'ChatGPT' },
default: { icon: <OpenAIMinimalIcon />, name: 'UNKNOWN' },
};

View file

@ -0,0 +1,27 @@
import Settings from '../Google';
import Examples from '../Examples';
import { useSetIndexOptions } from '~/hooks';
import { useChatContext } from '~/Providers';
export default function GoogleView({ conversation, models, isPreset = false }) {
const { optionSettings } = useChatContext();
const { setOption, setExample, addExample, removeExample } = useSetIndexOptions(
isPreset ? conversation : null,
);
if (!conversation) {
return null;
}
const { examples } = conversation;
const { showExamples, isCodeChat } = optionSettings;
return showExamples && !isCodeChat ? (
<Examples
examples={examples ?? []}
setExample={setExample}
addExample={addExample}
removeExample={removeExample}
/>
) : (
<Settings conversation={conversation} setOption={setOption} models={models} />
);
}

View file

@ -0,0 +1,18 @@
import Settings from '../Plugins';
import AgentSettings from '../AgentSettings';
import { useSetIndexOptions } from '~/hooks';
import { useChatContext } from '~/Providers';
export default function PluginsView({ conversation, models, isPreset = false }) {
const { showAgentSettings } = useChatContext();
const { setOption, setAgentOption } = useSetIndexOptions(isPreset ? conversation : null);
if (!conversation) {
return null;
}
return showAgentSettings ? (
<AgentSettings conversation={conversation} setOption={setAgentOption} models={models} />
) : (
<Settings conversation={conversation} setOption={setOption} models={models} />
);
}

View file

@ -1,2 +1,4 @@
export { default as GoogleSettings } from './Google';
export { default as PluginsSettings } from './Plugins';
export { default as Google } from './Google';
export { default as Plugins } from './Plugins';
export { default as GoogleSettings } from './GoogleSettings';
export { default as PluginSettings } from './PluginSettings';

View file

@ -67,6 +67,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
placeholder={localize('com_endpoint_openai_custom_name_placeholder')}
className={cn(
defaultTextProps,
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
@ -85,6 +86,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
placeholder={localize('com_endpoint_openai_prompt_prefix_placeholder')}
className={cn(
defaultTextProps,
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2 ',
)}
/>

View file

@ -5,3 +5,4 @@ export { default as PluginsSettings } from './Plugins';
export { default as Examples } from './Examples';
export { default as AgentSettings } from './AgentSettings';
export { default as AnthropicSettings } from './Anthropic';
export * from './settings';

View file

@ -0,0 +1,36 @@
import { EModelEndpoint } from 'librechat-data-provider';
import OpenAISettings from './OpenAI';
import BingAISettings from './BingAI';
import AnthropicSettings from './Anthropic';
import { Google, Plugins, GoogleSettings, PluginSettings } from './MultiView';
import type { FC } from 'react';
import type { TModelSelectProps, TBaseSettingsProps, TModels } from '~/common';
const settings: { [key: string]: FC<TModelSelectProps> } = {
[EModelEndpoint.openAI]: OpenAISettings,
[EModelEndpoint.azureOpenAI]: OpenAISettings,
[EModelEndpoint.bingAI]: BingAISettings,
[EModelEndpoint.anthropic]: AnthropicSettings,
};
const multiViewSettings: { [key: string]: FC<TBaseSettingsProps & TModels> } = {
[EModelEndpoint.google]: Google,
[EModelEndpoint.gptPlugins]: Plugins,
};
export const getSettings = (isMultiChat = false) => {
if (!isMultiChat) {
return {
settings,
multiViewSettings,
};
}
return {
settings,
multiViewSettings: {
[EModelEndpoint.google]: GoogleSettings,
[EModelEndpoint.gptPlugins]: PluginSettings,
},
};
};

View file

@ -1,5 +1,6 @@
export { default as Icon } from './Icon';
export { default as MinimalIcon } from './MinimalIcon';
export { default as PopoverButtons } from './PopoverButtons';
export { default as EndpointSettings } from './EndpointSettings';
export { default as EditPresetDialog } from './EditPresetDialog';
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';

View file

@ -4,9 +4,9 @@ import { Settings } from 'lucide-react';
import { DropdownMenuRadioItem } from '~/components';
import { Icon } from '~/components/Endpoints';
import { SetKeyDialog } from '../SetKeyDialog';
import { alternateName } from '~/common';
import { useLocalize } from '~/hooks';
import { cn, alternateName } from '~/utils';
import { cn } from '~/utils';
export default function ModelItem({
endpoint,

View file

@ -37,7 +37,7 @@ export default function NewConversationMenu() {
const [menuOpen, setMenuOpen] = useState(false);
const [showPresets, setShowPresets] = useState(true);
const [showEndpoints, setShowEndpoints] = useState(true);
const [presetModelVisible, setPresetModelVisible] = useState(false);
const [presetModalVisible, setPresetModalVisible] = useState(false);
const [preset, setPreset] = useState(false);
const [conversation, setConversation] = useRecoilState(store.conversation) ?? {};
const [messages, setMessages] = useRecoilState(store.messages);
@ -131,7 +131,7 @@ export default function NewConversationMenu() {
};
const onChangePreset = (preset) => {
setPresetModelVisible(true);
setPresetModalVisible(true);
setPreset(preset);
};
@ -269,8 +269,8 @@ export default function NewConversationMenu() {
</DropdownMenuContent>
</DropdownMenu>
<EditPresetDialog
open={presetModelVisible}
onOpenChange={setPresetModelVisible}
open={presetModalVisible}
onOpenChange={setPresetModalVisible}
preset={preset}
/>
</Dialog>

View file

@ -4,7 +4,7 @@ import { cn } from '~/utils/';
import { useLocalize } from '~/hooks';
type FileUploadProps = {
onFileSelected: (event: React.ChangeEvent<HTMLInputElement>) => void;
onFileSelected: (jsonData: Record<string, unknown>) => void;
className?: string;
successText?: string;
invalidText?: string;

View file

@ -1,5 +1,6 @@
import type { TPresetItemProps } from '~/common';
import type { TPreset } from 'librechat-data-provider';
import { EModelEndpoint } from 'librechat-data-provider';
import { DropdownMenuRadioItem, EditIcon, TrashIcon } from '~/components';
import { Icon } from '~/components/Endpoints';
@ -24,32 +25,32 @@ export default function PresetItem({
let _title = `${endpoint}`;
const { chatGptLabel, modelLabel, model, jailbreak, toneStyle } = preset;
if (endpoint === 'azureOpenAI' || endpoint === 'openAI') {
if (endpoint === EModelEndpoint.azureOpenAI || endpoint === EModelEndpoint.openAI) {
if (model) {
_title += `: ${model}`;
}
if (chatGptLabel) {
_title += ` as ${chatGptLabel}`;
}
} else if (endpoint === 'google') {
} else if (endpoint === EModelEndpoint.google) {
if (model) {
_title += `: ${model}`;
}
if (modelLabel) {
_title += ` as ${modelLabel}`;
}
} else if (endpoint === 'bingAI') {
} else if (endpoint === EModelEndpoint.bingAI) {
if (toneStyle) {
_title += `: ${toneStyle}`;
}
if (jailbreak) {
_title += ' as Sydney';
}
} else if (endpoint === 'chatGPTBrowser') {
} else if (endpoint === EModelEndpoint.chatGPTBrowser) {
if (model) {
_title += `: ${model}`;
}
} else if (endpoint === 'gptPlugins') {
} else if (endpoint === EModelEndpoint.gptPlugins) {
if (model) {
_title += `: ${model}`;
}

View file

@ -1,14 +1,21 @@
import { SelectDropDown } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { cn, cardStyle } from '~/utils/';
export default function Anthropic({ conversation, setOption, models }: TModelSelectProps) {
export default function Anthropic({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
const Menu = popover ? SelectDropDownPop : SelectDropDown;
return (
<SelectDropDown
<Menu
value={conversation?.model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={true}
showAbove={showAbove}
showLabel={false}
className={cn(
cardStyle,

View file

@ -1,10 +1,17 @@
import { useRecoilValue } from 'recoil';
import { SelectDropDown, Tabs, TabsList, TabsTrigger } from '~/components/ui';
import { SelectDropDown, SelectDropDownPop, Tabs, TabsList, TabsTrigger } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import type { TModelSelectProps } from '~/common';
import store from '~/store';
export default function BingAI({ conversation, setOption, models }: TModelSelectProps) {
export default function BingAI({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
// TODO: index family bing tone settings, important for multiview
const showBingToneSetting = useRecoilValue(store.showBingToneSetting);
if (!conversation) {
return null;
@ -21,16 +28,17 @@ export default function BingAI({ conversation, setOption, models }: TModelSelect
'font-medium data-[state=active]:text-white text-xs text-white',
);
const selectedClass = (val: string) => val + '-tab ' + defaultSelected;
const Menu = popover ? SelectDropDownPop : SelectDropDown;
return (
<>
<SelectDropDown
<Menu
title="Mode"
value={jailbreak ? 'Sydney' : 'BingAI'}
data-testid="bing-select-dropdown"
setValue={(value) => setOption('jailbreak')(value === 'Sydney')}
availableValues={models}
showAbove={true}
showAbove={showAbove}
showLabel={false}
className={cn(
cardStyle,

View file

@ -1,8 +1,14 @@
import { SelectDropDown } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { cn, cardStyle } from '~/utils/';
export default function ChatGPT({ conversation, setOption, models }: TModelSelectProps) {
export default function ChatGPT({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
if (!conversation) {
return null;
}
@ -10,13 +16,13 @@ export default function ChatGPT({ conversation, setOption, models }: TModelSelec
if (conversationId !== 'new') {
return null;
}
const Menu = popover ? SelectDropDownPop : SelectDropDown;
return (
<SelectDropDown
<Menu
value={model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={true}
showAbove={showAbove}
showLabel={false}
className={cn(
cardStyle,

View file

@ -1,14 +1,21 @@
import { SelectDropDown } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { cn, cardStyle } from '~/utils/';
export default function Google({ conversation, setOption, models }: TModelSelectProps) {
export default function Google({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
const Menu = popover ? SelectDropDownPop : SelectDropDown;
return (
<SelectDropDown
<Menu
value={conversation?.model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={true}
showAbove={showAbove}
showLabel={false}
className={cn(
cardStyle,

View file

@ -1,13 +1,7 @@
import React from 'react';
import OpenAI from './OpenAI';
import BingAI from './BingAI';
import Google from './Google';
import Plugins from './Plugins';
import ChatGPT from './ChatGPT';
import Anthropic from './Anthropic';
import { useRecoilValue } from 'recoil';
import type { TConversation } from 'librechat-data-provider';
import type { TSetOption, TModelSelectProps } from '~/common';
import type { TSetOption } from '~/common';
import { options, multiChatOptions } from './options';
import store from '~/store';
type TGoogleProps = {
@ -19,31 +13,36 @@ type TSelectProps = {
conversation: TConversation | null;
setOption: TSetOption;
extraProps?: TGoogleProps;
isMultiChat?: boolean;
showAbove?: boolean;
};
const optionComponents: { [key: string]: React.FC<TModelSelectProps> } = {
openAI: OpenAI,
azureOpenAI: OpenAI,
bingAI: BingAI,
google: Google,
gptPlugins: Plugins,
anthropic: Anthropic,
chatGPTBrowser: ChatGPT,
};
export default function ModelSelect({ conversation, setOption }: TSelectProps) {
export default function ModelSelect({
conversation,
setOption,
isMultiChat = false,
showAbove = true,
}: TSelectProps) {
const modelsConfig = useRecoilValue(store.modelsConfig);
if (!conversation?.endpoint) {
return null;
}
const { endpoint } = conversation;
const OptionComponent = optionComponents[endpoint];
const OptionComponent = isMultiChat ? multiChatOptions[endpoint] : options[endpoint];
const models = modelsConfig?.[endpoint] ?? [];
if (!OptionComponent) {
return null;
}
return <OptionComponent conversation={conversation} setOption={setOption} models={models} />;
return (
<OptionComponent
conversation={conversation}
setOption={setOption}
models={models}
showAbove={showAbove}
popover={isMultiChat}
/>
);
}

View file

@ -1,14 +1,21 @@
import { SelectDropDown } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { cn, cardStyle } from '~/utils/';
export default function OpenAI({ conversation, setOption, models }: TModelSelectProps) {
export default function OpenAI({
conversation,
setOption,
models,
showAbove = true,
popover = false,
}: TModelSelectProps) {
const Menu = popover ? SelectDropDownPop : SelectDropDown;
return (
<SelectDropDown
<Menu
value={conversation?.model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={true}
showAbove={showAbove}
showLabel={false}
className={cn(
cardStyle,

View file

@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { useAvailablePluginsQuery, TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { SelectDropDown, MultiSelectDropDown, Button } from '~/components/ui';
import { SelectDropDown, MultiSelectDropDown, SelectDropDownPop, Button } from '~/components/ui';
import { useSetOptions, useAuthContext, useMediaQuery } from '~/hooks';
import { cn, cardStyle } from '~/utils/';
import store from '~/store';
@ -18,13 +18,20 @@ const pluginStore: TPlugin = {
authenticated: false,
};
export default function Plugins({ conversation, setOption, models }: TModelSelectProps) {
export default function Plugins({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
const { data: allPlugins } = useAvailablePluginsQuery();
const [visible, setVisibility] = useState<boolean>(true);
const [availableTools, setAvailableTools] = useRecoilState(store.availableTools);
const { checkPluginSelection, setTools } = useSetOptions();
const { user } = useAuthContext();
const isSmallScreen = useMediaQuery('(max-width: 640px)');
const Menu = popover ? SelectDropDownPop : SelectDropDown;
useEffect(() => {
if (isSmallScreen) {
@ -87,11 +94,11 @@ export default function Plugins({ conversation, setOption, models }: TModelSelec
)}
/>
</Button>
<SelectDropDown
<Menu
value={conversation.model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={true}
showAbove={showAbove}
className={cn(cardStyle, 'min-w-60 z-40 flex w-64 sm:w-48', visible ? '' : 'hidden')}
/>
<MultiSelectDropDown
@ -100,7 +107,7 @@ export default function Plugins({ conversation, setOption, models }: TModelSelec
setSelected={setTools}
availableValues={availableTools}
optionValueKey="pluginKey"
showAbove={true}
showAbove={showAbove}
className={cn(cardStyle, 'min-w-60 z-50 w-64 sm:w-48', visible ? '' : 'hidden')}
/>
</>

View file

@ -0,0 +1,125 @@
import { useRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { useAvailablePluginsQuery, TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import {
SelectDropDown,
SelectDropDownPop,
MultiSelectDropDown,
MultiSelectPop,
Button,
} from '~/components/ui';
import { useSetIndexOptions, useAuthContext, useMediaQuery } from '~/hooks';
import { cn, cardStyle } from '~/utils/';
import store from '~/store';
const pluginStore: TPlugin = {
name: 'Plugin store',
pluginKey: 'pluginStore',
isButton: true,
description: '',
icon: '',
authConfig: [],
authenticated: false,
};
export default function PluginsByIndex({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
const { data: allPlugins } = useAvailablePluginsQuery();
const [visible, setVisibility] = useState<boolean>(true);
const [availableTools, setAvailableTools] = useRecoilState(store.availableTools);
const { checkPluginSelection, setTools } = useSetIndexOptions();
const { user } = useAuthContext();
const isSmallScreen = useMediaQuery('(max-width: 640px)');
useEffect(() => {
if (isSmallScreen) {
setVisibility(false);
}
}, [isSmallScreen]);
useEffect(() => {
if (!user) {
return;
}
if (!allPlugins) {
return;
}
if (!user.plugins || user.plugins.length === 0) {
setAvailableTools([pluginStore]);
return;
}
const tools = [...user.plugins]
.map((el) => allPlugins.find((plugin: TPlugin) => plugin.pluginKey === el))
.filter((el): el is TPlugin => el !== undefined);
/* Filter Last Selected Tools */
const localStorageItem = localStorage.getItem('lastSelectedTools');
if (!localStorageItem) {
return setAvailableTools([...tools, pluginStore]);
}
const lastSelectedTools = JSON.parse(localStorageItem);
const filteredTools = lastSelectedTools.filter((tool: TPlugin) =>
tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey),
);
localStorage.setItem('lastSelectedTools', JSON.stringify(filteredTools));
setAvailableTools([...tools, pluginStore]);
// setAvailableTools is a recoil state setter, so it's safe to use it in useEffect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allPlugins, user]);
if (!conversation) {
return null;
}
const Menu = popover ? SelectDropDownPop : SelectDropDown;
const PluginsMenu = popover ? MultiSelectPop : MultiSelectDropDown;
return (
<>
<Button
type="button"
className={cn(
cardStyle,
'min-w-4 z-40 flex h-[40px] flex-none items-center justify-center px-3 hover:bg-white focus:ring-0 focus:ring-offset-0 dark:hover:bg-gray-700',
)}
onClick={() => setVisibility((prev) => !prev)}
>
<ChevronDownIcon
className={cn(
!visible ? '' : 'rotate-180 transform',
'w-4 text-gray-600 dark:text-white',
)}
/>
</Button>
<Menu
value={conversation.model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={showAbove}
showLabel={false}
className={cn(cardStyle, 'min-w-60 z-40 flex w-64 sm:w-48 ', visible ? '' : 'hidden')}
/>
<PluginsMenu
value={conversation.tools || []}
isSelected={checkPluginSelection}
setSelected={setTools}
availableValues={availableTools}
optionValueKey="pluginKey"
showAbove={false}
showLabel={false}
className={cn(cardStyle, 'min-w-60 z-50 w-64 sm:w-48 ', visible ? '' : 'hidden')}
/>
</>
);
}

View file

@ -0,0 +1,26 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import type { FC } from 'react';
import OpenAI from './OpenAI';
import BingAI from './BingAI';
import Google from './Google';
import Plugins from './Plugins';
import ChatGPT from './ChatGPT';
import Anthropic from './Anthropic';
import PluginsByIndex from './PluginsByIndex';
export const options: { [key: string]: FC<TModelSelectProps> } = {
[EModelEndpoint.openAI]: OpenAI,
[EModelEndpoint.azureOpenAI]: OpenAI,
[EModelEndpoint.bingAI]: BingAI,
[EModelEndpoint.google]: Google,
[EModelEndpoint.gptPlugins]: Plugins,
[EModelEndpoint.anthropic]: Anthropic,
[EModelEndpoint.chatGPTBrowser]: ChatGPT,
};
export const multiChatOptions = {
...options,
[EModelEndpoint.gptPlugins]: PluginsByIndex,
};

View file

@ -1,9 +1,10 @@
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';
import { tPresetSchema } from 'librechat-data-provider';
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
import { PluginStoreDialog } from '~/components';
import {
PopoverButtons,
EndpointSettings,
SaveAsPresetDialog,
EndpointOptionsPopover,
@ -40,8 +41,8 @@ export default function OptionsBar() {
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
chatGPTBrowser: true,
bingAI: jailbreak ? false : conversationId !== 'new',
[EModelEndpoint.chatGPTBrowser]: true,
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
}),
[jailbreak, conversationId],
);
@ -133,10 +134,10 @@ export default function OptionsBar() {
)}
</div>
<EndpointOptionsPopover
endpoint={endpoint}
visible={showPopover}
saveAsPreset={saveAsPreset}
closePopover={() => setShowPopover(false)}
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
>
<div className="px-4 py-4">
<EndpointSettings conversation={conversation} setOption={setOption} />

View file

@ -1,10 +1,11 @@
import React from 'react';
import { memo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
function HelpText({ endpoint }: { endpoint: string }) {
const localize = useLocalize();
const textMap = {
bingAI: (
[EModelEndpoint.bingAI]: (
<small className="break-all text-gray-600">
{localize('com_endpoint_config_key_get_edge_key')}{' '}
<a
@ -28,7 +29,7 @@ function HelpText({ endpoint }: { endpoint: string }) {
{localize('com_endpoint_config_key_edge_full_token_string')}
</small>
),
chatGPTBrowser: (
[EModelEndpoint.chatGPTBrowser]: (
<small className="break-all text-gray-600">
{localize('com_endpoint_config_key_chatgpt')}{' '}
<a
@ -53,7 +54,7 @@ function HelpText({ endpoint }: { endpoint: string }) {
{localize('com_endpoint_config_key_chatgpt_copy_token')}
</small>
),
google: (
[EModelEndpoint.google]: (
<small className="break-all text-gray-600">
{localize('com_endpoint_config_key_google_need_to')}{' '}
<a
@ -82,4 +83,4 @@ function HelpText({ endpoint }: { endpoint: string }) {
return textMap[endpoint] || null;
}
export default React.memo(HelpText);
export default memo(HelpText);

View file

@ -1,20 +1,21 @@
import React, { useState } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TDialogProps } from '~/common';
import { Dialog, Dropdown } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { RevokeKeysButton } from '~/components/Nav';
import { cn, alternateName } from '~/utils';
import { Dialog, Dropdown } from '~/components/ui';
import { useUserKey, useLocalize } from '~/hooks';
import GoogleConfig from './GoogleConfig';
import OpenAIConfig from './OpenAIConfig';
import { alternateName } from '~/common';
import OtherConfig from './OtherConfig';
import HelpText from './HelpText';
const endpointComponents = {
google: GoogleConfig,
openAI: OpenAIConfig,
azureOpenAI: OpenAIConfig,
gptPlugins: OpenAIConfig,
[EModelEndpoint.google]: GoogleConfig,
[EModelEndpoint.openAI]: OpenAIConfig,
[EModelEndpoint.azureOpenAI]: OpenAIConfig,
[EModelEndpoint.gptPlugins]: OpenAIConfig,
default: OtherConfig,
};

View file

@ -20,7 +20,7 @@ type CodeBlockProps = Pick<CodeBarProps, 'lang' | 'plugin' | 'error'> & {
const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, error, plugin = null }) => {
const [isCopied, setIsCopied] = useState(false);
return (
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200">
<div className="relative flex items-center rounded-tl-md rounded-tr-md bg-gray-800 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-900">
<span className="">{lang}</span>
{plugin ? (
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-white/50" />

View file

@ -1,10 +1,12 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import type { TPreset } from 'librechat-data-provider';
import { Plugin } from '~/components/svg';
import { EModelEndpoint } from 'librechat-data-provider';
import EndpointOptionsDialog from '../Endpoints/EndpointOptionsDialog';
import { cn, alternateName } from '~/utils/';
import { Plugin } from '~/components/svg';
import { alternateName } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -24,7 +26,7 @@ const MessageHeader = ({ isSearchView = false }) => {
return null;
}
const isNotClickable = endpoint === 'chatGPTBrowser';
const isNotClickable = endpoint === EModelEndpoint.chatGPTBrowser;
const plugins = (
<>
@ -43,7 +45,7 @@ const MessageHeader = ({ isSearchView = false }) => {
} else {
let _title = `${alternateName[endpoint] ?? endpoint}`;
if (endpoint === 'azureOpenAI' || endpoint === 'openAI') {
if (endpoint === EModelEndpoint.azureOpenAI || endpoint === EModelEndpoint.openAI) {
const { chatGptLabel } = conversation;
if (model) {
_title += `: ${model}`;
@ -51,7 +53,7 @@ const MessageHeader = ({ isSearchView = false }) => {
if (chatGptLabel) {
_title += ` as ${chatGptLabel}`;
}
} else if (endpoint === 'google') {
} else if (endpoint === EModelEndpoint.google) {
_title = 'PaLM';
const { modelLabel, model } = conversation;
if (model) {
@ -60,7 +62,7 @@ const MessageHeader = ({ isSearchView = false }) => {
if (modelLabel) {
_title += ` as ${modelLabel}`;
}
} else if (endpoint === 'bingAI') {
} else if (endpoint === EModelEndpoint.bingAI) {
const { jailbreak, toneStyle } = conversation;
if (toneStyle) {
_title += `: ${toneStyle}`;
@ -68,13 +70,13 @@ const MessageHeader = ({ isSearchView = false }) => {
if (jailbreak) {
_title += ' as Sydney';
}
} else if (endpoint === 'chatGPTBrowser') {
} else if (endpoint === EModelEndpoint.chatGPTBrowser) {
if (model) {
_title += `: ${model}`;
}
} else if (endpoint === 'gptPlugins') {
} else if (endpoint === EModelEndpoint.gptPlugins) {
return plugins;
} else if (endpoint === 'anthropic') {
} else if (endpoint === EModelEndpoint.anthropic) {
_title = 'Claude';
} else if (endpoint === null) {
null;

View file

@ -4,32 +4,26 @@ import {
useSearchQuery,
TSearchResults,
} from 'librechat-data-provider';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import NewChat from './NewChat';
import SearchBar from './SearchBar';
import NavLinks from './NavLinks';
import { Panel, Spinner } from '~/components';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAuthContext, useMediaQuery, useConversation, useConversations } from '~/hooks';
import { TooltipProvider, Tooltip } from '~/components/ui';
import { Conversations, Pages } from '../Conversations';
import {
useAuthContext,
useMediaQuery,
useLocalize,
useConversation,
useConversations,
} from '~/hooks';
import { cn } from '~/utils/';
import { Spinner } from '~/components';
import SearchBar from './SearchBar';
import NavToggle from './NavToggle';
import NavLinks from './NavLinks';
import NewChat from './NewChat';
import { cn } from '~/utils';
import store from '~/store';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
export default function Nav({ navVisible, setNavVisible }) {
const [isToggleHovering, setIsToggleHovering] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [navWidth, setNavWidth] = useState('260px');
const { isAuthenticated } = useAuthContext();
const containerRef = useRef<HTMLDivElement | null>(null);
const scrollPositionRef = useRef<number | null>(null);
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
useEffect(() => {
@ -156,11 +150,11 @@ export default function Nav({ navVisible, setNavVisible }) {
: 'flex flex-col gap-2 text-gray-100 text-sm';
return (
<TooltipProvider delayDuration={300}>
<TooltipProvider delayDuration={150}>
<Tooltip>
<div
className={
'nav active dark max-w-[320px] flex-shrink-0 overflow-x-hidden bg-gray-900 md:max-w-[260px]'
'nav active dark max-w-[320px] flex-shrink-0 overflow-x-hidden bg-black md:max-w-[260px]'
}
style={{
width: navVisible ? navWidth : '0px',
@ -170,25 +164,15 @@ export default function Nav({ navVisible, setNavVisible }) {
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full min-h-0 flex-col">
<div className="scrollbar-trigger relative flex h-full w-full flex-1 items-start border-white/20">
<div
className={cn(
'scrollbar-trigger relative flex h-full w-full flex-1 items-start border-white/20 transition-opacity',
isToggleHovering ? 'opacity-50' : 'opacity-100',
)}
>
<nav className="relative flex h-full flex-1 flex-col space-y-1 p-2">
<div className="mb-1 flex h-11 flex-row">
<NewChat />
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'nav-close-button inline-flex h-11 w-11 items-center justify-center rounded-md border border-white/20 text-white hover:bg-gray-500/10',
)}
onClick={toggleNavVisible}
>
<span className="sr-only">{localize('com_nav_close_sidebar')}</span>
<Panel open={false} />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={17}>
{localize('com_nav_close_menu')}
</TooltipContent>
</div>
{isSearchEnabled && <SearchBar clearSearch={clearSearch} />}
<div
@ -220,27 +204,13 @@ export default function Nav({ navVisible, setNavVisible }) {
</div>
</div>
</div>
{!navVisible && (
<div className="absolute left-2 top-2 z-20 hidden md:inline-block">
<TooltipTrigger asChild>
<button
type="button"
className="nav-open-button flex h-11 cursor-pointer items-center gap-3 rounded-md border border-black/10 bg-white p-3 text-sm text-black transition-colors duration-200 hover:bg-gray-50 dark:border-white/20 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
onClick={toggleNavVisible}
>
<div className="flex items-center justify-center">
<span className="sr-only">{localize('com_nav_open_sidebar')}</span>
<Panel open={true} />
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={17}>
{localize('com_nav_open_menu')}
</TooltipContent>
</div>
)}
<div className={`nav-mask${navVisible ? ' active' : ''}`} onClick={toggleNavVisible}></div>
<NavToggle
isHovering={isToggleHovering}
setIsHovering={setIsToggleHovering}
onToggle={toggleNavVisible}
navVisible={navVisible}
/>
<div className={`nav-mask${navVisible ? ' active' : ''}`} onClick={toggleNavVisible} />
</Tooltip>
</TooltipProvider>
);

View file

@ -0,0 +1,55 @@
import { TooltipTrigger, TooltipContent } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function NavToggle({ onToggle, navVisible, isHovering, setIsHovering }) {
const localize = useLocalize();
const transition = {
transition: 'transform 0.3s ease, opacity 0.2s ease',
};
return (
<div
className={cn(
'fixed left-0 top-1/2 z-40 -translate-y-1/2 transition-transform',
navVisible ? 'translate-x-[260px] rotate-0' : 'translate-x-0 rotate-180',
)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<TooltipTrigger asChild>
<button onClick={onToggle}>
<span className="" data-state="closed">
<div
className="flex h-[72px] w-8 items-center justify-center"
style={{ ...transition, opacity: isHovering ? 1 : 0.25 }}
>
<div className="flex h-6 w-6 flex-col items-center">
<div
className="h-3 w-1 rounded-full bg-black dark:bg-white"
style={{
...transition,
transform: `translateY(0.15rem) rotate(${
isHovering || !navVisible ? '15' : '0'
}deg) translateZ(0px)`,
}}
/>
<div
className="h-3 w-1 rounded-full bg-black dark:bg-white"
style={{
...transition,
transform: `translateY(-0.15rem) rotate(-${
isHovering || !navVisible ? '15' : '0'
}deg) translateZ(0px)`,
}}
/>
</div>
</div>
<TooltipContent side="right" sideOffset={4}>
{navVisible ? localize('com_nav_close_menu') : localize('com_nav_open_menu')}
</TooltipContent>
</span>
</button>
</TooltipTrigger>
</div>
);
}

View file

@ -1,14 +1,15 @@
import { useNavigate } from 'react-router-dom';
import { useLocalize, useConversation } from '~/hooks';
import { useLocalize, useConversation, useNewConvo, useOriginNavigate } from '~/hooks';
export default function NewChat() {
const { newConversation } = useConversation();
const navigate = useNavigate();
const { newConversation: newConvo } = useNewConvo();
const navigate = useOriginNavigate();
const localize = useLocalize();
const clickHandler = () => {
newConvo();
newConversation();
navigate('/chat/new');
navigate('new');
};
return (

View file

@ -1,6 +1,13 @@
export default function AnthropicIcon({ size = 25 }) {
import { cn } from '~/utils';
export default function AnthropicIcon({ size = 25, className = '' }) {
return (
<svg viewBox="0 0 24 16" overflow="visible" width={size} height={size}>
<svg
viewBox="0 0 24 16"
overflow="visible"
width={size}
height={size}
className={cn('fill-current text-black', className)}
>
<g
style={{
transform: 'translateX(13px) rotateZ(0deg)',
@ -9,7 +16,7 @@ export default function AnthropicIcon({ size = 25 }) {
>
<path
shapeRendering="geometricPrecision"
fill="rgb(24,24,24)"
// fill="rgb(24,24,24)"
fillOpacity="1"
d=" M0,0 C0,0 6.1677093505859375,15.470022201538086 6.1677093505859375,15.470022201538086 C6.1677093505859375,15.470022201538086 9.550004005432129,15.470022201538086 9.550004005432129,15.470022201538086 C9.550004005432129,15.470022201538086 3.382294178009033,0 3.382294178009033,0 C3.382294178009033,0 0,0 0,0 C0,0 0,0 0,0z"
></path>
@ -23,7 +30,7 @@ export default function AnthropicIcon({ size = 25 }) {
>
<path
shapeRendering="geometricPrecision"
fill="rgb(24,24,24)"
// fill="rgb(24,24,24)"
fillOpacity="1"
d=" M5.824605464935303,9.348296165466309 C5.824605464935303,9.348296165466309 7.93500280380249,3.911694288253784 7.93500280380249,3.911694288253784 C7.93500280380249,3.911694288253784 10.045400619506836,9.348296165466309 10.045400619506836,9.348296165466309 C10.045400619506836,9.348296165466309 5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309 C5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309 5.824605464935303,9.348296165466309z M6.166755199432373,0 C6.166755199432373,0 0,15.470022201538086 0,15.470022201538086 C0,15.470022201538086 3.4480772018432617,15.470022201538086 3.4480772018432617,15.470022201538086 C3.4480772018432617,15.470022201538086 4.709278583526611,12.22130012512207 4.709278583526611,12.22130012512207 C4.709278583526611,12.22130012512207 11.16093635559082,12.22130012512207 11.16093635559082,12.22130012512207 C11.16093635559082,12.22130012512207 12.421928405761719,15.470022201538086 12.421928405761719,15.470022201538086 C12.421928405761719,15.470022201538086 15.87000560760498,15.470022201538086 15.87000560760498,15.470022201538086 C15.87000560760498,15.470022201538086 9.703250885009766,0 9.703250885009766,0 C9.703250885009766,0 6.166755199432373,0 6.166755199432373,0 C6.166755199432373,0 6.166755199432373,0 6.166755199432373,0z"
></path>

View file

@ -0,0 +1,19 @@
export default function AttachmentIcon({ className = '' }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 7C9 4.23858 11.2386 2 14 2C16.7614 2 19 4.23858 19 7V15C19 18.866 15.866 22 12 22C8.13401 22 5 18.866 5 15V9C5 8.44772 5.44772 8 6 8C6.55228 8 7 8.44772 7 9V15C7 17.7614 9.23858 20 12 20C14.7614 20 17 17.7614 17 15V7C17 5.34315 15.6569 4 14 4C12.3431 4 11 5.34315 11 7V15C11 15.5523 11.4477 16 12 16C12.5523 16 13 15.5523 13 15V9C13 8.44772 13.4477 8 14 8C14.5523 8 15 8.44772 15 9V15C15 16.6569 13.6569 18 12 18C10.3431 18 9 16.6569 9 15V7Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -1,6 +1,5 @@
import React from 'react';
export default function BingAIMinimalIcon() {
import { cn } from '~/utils';
export default function BingAIMinimalIcon({ className = '' }) {
return (
<svg
stroke="currentColor"
@ -9,7 +8,7 @@ export default function BingAIMinimalIcon() {
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
className={cn('h-4 w-4', className)}
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"

View file

@ -1,6 +1,6 @@
import React from 'react';
import { cn } from '~/utils';
export default function CheckMark() {
export default function CheckMark({ className = '' }: { className?: string }) {
return (
<svg
stroke="currentColor"
@ -9,7 +9,7 @@ export default function CheckMark() {
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
className={cn('h-4 w-4', className)}
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"

View file

@ -1,6 +1,6 @@
import React from 'react';
import { cn } from '~/utils';
export default function LightningIcon() {
export default function LightningIcon({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -9,7 +9,7 @@ export default function LightningIcon() {
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
className="h-6 w-6"
className={cn('h-6 w-6', className)}
>
<path
strokeLinecap="round"

View file

@ -0,0 +1,21 @@
import { cn } from '~/utils';
export default function MinimalPlugin({ className = '' }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('icon-md', className)}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.4646 19C15.2219 20.6961 13.7632 22 12 22C10.2368 22 8.77806 20.6961 8.53544 19H6C4.34315 19 3 17.6569 3 16V13.5C3 12.9477 3.44772 12.5 4 12.5H4.5C5.32843 12.5 6 11.8284 6 11C6 10.1716 5.32843 9.5 4.5 9.5H4C3.44772 9.5 3 9.05229 3 8.5L3 6C3 4.34315 4.34315 3 6 3L18 3C19.6569 3 21 4.34315 21 6L21 16C21 17.6569 19.6569 19 18 19H15.4646ZM12 20C12.8284 20 13.5 19.3284 13.5 18.5V18C13.5 17.4477 13.9477 17 14.5 17H18C18.5523 17 19 16.5523 19 16L19 6C19 5.44772 18.5523 5 18 5L6 5C5.44772 5 5 5.44772 5 6V7.53544C6.69615 7.77806 8 9.23676 8 11C8 12.7632 6.69615 14.2219 5 14.4646L5 16C5 16.5523 5.44771 17 6 17H9.5C10.0523 17 10.5 17.4477 10.5 18V18.5C10.5 19.3284 11.1716 20 12 20Z"
fill="currentColor"
></path>
</svg>
);
}

View file

@ -1,5 +1,3 @@
import React from 'react';
export default function RenameIcon() {
return (
<svg

View file

@ -0,0 +1,21 @@
import { cn } from '~/utils';
export default function SendIcon({ size = 24, className = '' }) {
return (
<svg
width={size}
height={size}
viewBox={'0 0 24 24'}
fill="none"
className={cn('text-white dark:text-black', className)}
>
<path
d="M7 11L12 6L17 11M12 18V7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View file

@ -8,6 +8,8 @@ export { default as Clipboard } from './Clipboard';
export { default as CheckMark } from './CheckMark';
export { default as CrossIcon } from './CrossIcon';
export { default as LogOutIcon } from './LogOutIcon';
export { default as LightningIcon } from './LightningIcon';
export { default as AttachmentIcon } from './AttachmentIcon';
export { default as MessagesSquared } from './MessagesSquared';
export { default as StopGeneratingIcon } from './StopGeneratingIcon';
export { default as RegenerateIcon } from './RegenerateIcon';
@ -18,10 +20,13 @@ export { default as OpenIDIcon } from './OpenIDIcon';
export { default as GithubIcon } from './GithubIcon';
export { default as DiscordIcon } from './DiscordIcon';
export { default as AnthropicIcon } from './AnthropicIcon';
export { default as RenameIcon } from './RenameIcon';
export { default as SendIcon } from './SendIcon';
export { default as LinkIcon } from './LinkIcon';
export { default as DotsIcon } from './DotsIcon';
export { default as GearIcon } from './GearIcon';
export { default as TrashIcon } from './TrashIcon';
export { default as MinimalPlugin } from './MinimalPlugin';
export { default as AzureMinimalIcon } from './AzureMinimalIcon';
export { default as OpenAIMinimalIcon } from './OpenAIMinimalIcon';
export { default as ChatGPTMinimalIcon } from './ChatGPTMinimalIcon';

View file

@ -35,10 +35,15 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
const Button = React.forwardRef<HTMLButtonElement, ButtonProps & { customId?: string }>(
({ className, variant, size, customId, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
id={customId ?? props?.id ?? 'shadcn-button'}
/>
);
},
);

View file

@ -0,0 +1,5 @@
import { useDelayedRender } from '~/hooks';
const DelayedRender = ({ delay, children }) => useDelayedRender(delay)(() => children);
export default DelayedRender;

View file

@ -0,0 +1,39 @@
import React, { useRef } from 'react';
type FileUploadProps = {
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onClick?: () => void;
className?: string;
children: React.ReactNode;
};
const FileUpload: React.FC<FileUploadProps> = ({
handleFileChange,
children,
onClick,
className = '',
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleButtonClick = () => {
if (onClick) {
onClick();
}
fileInputRef.current?.click();
};
return (
<div onClick={handleButtonClick} style={{ cursor: 'pointer' }} className={className}>
{children}
<input
ref={fileInputRef}
multiple
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
);
};
export default FileUpload;

View file

@ -0,0 +1,147 @@
import React from 'react';
import { Wrench, ArrowRight } from 'lucide-react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import type { TPlugin } from 'librechat-data-provider';
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import { cn } from '~/utils/';
type SelectDropDownProps = {
title?: string;
value: Array<{ icon?: string; name?: string; isButton?: boolean }>;
disabled?: boolean;
setSelected: (option: string) => void;
availableValues: TPlugin[];
showAbove?: boolean;
showLabel?: boolean;
containerClassName?: string;
isSelected: (value: string) => boolean;
className?: string;
optionValueKey?: string;
};
function SelectDropDownPop({
title: _title = 'Plugins',
value,
// TODO: do we need disabled here?
disabled,
setSelected,
availableValues,
showAbove = false,
showLabel = true,
containerClassName,
isSelected,
className,
optionValueKey = 'value',
}: SelectDropDownProps) {
// const localize = useLocalize();
const title = _title;
const excludeIds = ['select-plugin', 'plugins-label', 'selected-plugins'];
return (
<Root>
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
<div className="relative">
<Trigger asChild>
<button
data-testid="select-dropdown-button"
className={cn(
'relative flex cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-white/20 dark:bg-gray-800 dark:bg-gray-800 sm:text-sm',
className ?? '',
)}
>
{' '}
{showLabel && (
<label className="block text-xs text-gray-700 dark:text-gray-500 ">{title}</label>
)}
<span className="inline-flex" id={excludeIds[2]}>
<span
className={cn(
'flex h-6 items-center gap-1 text-sm text-gray-900 dark:text-white',
!showLabel ? 'text-xs' : '',
)}
>
{/* {!showLabel && title.length > 0 && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)} */}
<span className="flex items-center gap-1 ">
<div className="flex gap-1">
{value.length === 0 && 'None selected'}
{value.map((v, i) => (
<div key={i} className="relative">
{v.icon ? (
<img
src={v.icon}
alt={`${v} logo`}
className="icon-lg rounded-sm bg-white"
/>
) : (
<Wrench className="icon-lg rounded-sm bg-white" />
)}
<div className="absolute inset-0 rounded-sm ring-1 ring-inset ring-black/10" />
</div>
))}
</div>
</span>
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</span>
</button>
</Trigger>
<Portal>
<Content
side="bottom"
align="start"
className="mt-2 max-h-60 min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
{availableValues.map((option) => {
if (!option) {
return null;
}
const selected = isSelected(option[optionValueKey]);
return (
<MenuItem
key={`${option[optionValueKey]}`}
title={option.name}
value={option[optionValueKey]}
selected={selected}
onClick={() => setSelected(option.pluginKey)}
icon={
option.icon ? (
<img
src={option.icon}
alt={`${option.name} logo`}
className="icon-sm mr-1 rounded-sm bg-white bg-cover dark:bg-gray-800"
/>
) : (
<Wrench className="icon-sm mr-1 rounded-sm bg-white bg-cover dark:bg-gray-800" />
)
}
/>
);
})}
</Content>
</Portal>
</div>
</div>
</Root>
);
}
export default SelectDropDownPop;

View file

@ -1,34 +1,47 @@
import React from 'react';
import CheckMark from '../svg/CheckMark';
import { Listbox, Transition } from '@headlessui/react';
import { cn } from '~/utils/';
import type { Option } from '~/common';
import CheckMark from '../svg/CheckMark';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
type SelectDropDownProps = {
id?: string;
title?: string;
value: string;
value: string | null | Option;
disabled?: boolean;
setValue: (value: string) => void;
availableValues: string[];
availableValues: string[] | Option[];
emptyTitle?: boolean;
showAbove?: boolean;
showLabel?: boolean;
iconSide?: 'left' | 'right';
renderOption?: () => React.ReactNode;
containerClassName?: string;
currentValueClass?: string;
optionsListClass?: string;
optionsClass?: string;
subContainerClassName?: string;
className?: string;
};
function SelectDropDown({
title,
title: _title,
value,
disabled,
setValue,
availableValues,
showAbove = false,
showLabel = true,
emptyTitle = false,
iconSide = 'right',
containerClassName,
optionsListClass,
optionsClass,
currentValueClass,
subContainerClassName,
className,
renderOption,
}: SelectDropDownProps) {
const localize = useLocalize();
const transitionProps = { className: 'top-full mt-3' };
@ -36,12 +49,16 @@ function SelectDropDown({
transitionProps.className = 'bottom-full mb-3';
}
if (!title) {
let title = _title;
if (emptyTitle) {
title = '';
} else if (!title) {
title = localize('com_ui_model');
}
return (
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
<div className={cn('flex items-center justify-center gap-2 ', containerClassName ?? '')}>
<div className={cn('relative w-full', subContainerClassName ?? '')}>
<Listbox value={value} onChange={setValue} disabled={disabled}>
{({ open }) => (
@ -56,7 +73,7 @@ function SelectDropDown({
{' '}
{showLabel && (
<Listbox.Label
className="block text-xs text-gray-700 dark:text-gray-500"
className="block text-xs text-gray-700 dark:text-gray-500 "
id="headlessui-listbox-label-:r1:"
data-headlessui-state=""
>
@ -68,12 +85,13 @@ function SelectDropDown({
className={cn(
'flex h-6 items-center gap-1 truncate text-sm text-gray-900 dark:text-white',
!showLabel ? 'text-xs' : '',
currentValueClass ?? '',
)}
>
{!showLabel && (
{!showLabel && !emptyTitle && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)}
{value}
{typeof value !== 'string' && value ? value?.label ?? '' : value ?? ''}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
@ -102,30 +120,69 @@ function SelectDropDown({
leaveTo="opacity-0"
{...transitionProps}
>
<Listbox.Options className="absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded bg-white text-base text-xs ring-1 ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:last:border-0 md:w-[100%]">
{availableValues.map((option: string, i: number) => (
<Listbox.Options
className={cn(
'absolute z-10 mt-2 max-h-60 w-full overflow-auto rounded bg-white text-base text-xs ring-1 ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:last:border-0 md:w-[100%]',
optionsListClass ?? '',
)}
>
{renderOption && (
<Listbox.Option
key={i}
value={option}
className="group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden border-b border-black/10 pl-3 pr-9 text-gray-900 last:border-0 hover:bg-gray-20 dark:border-white/20 dark:text-white dark:hover:bg-gray-700"
key={'listbox-render-option'}
value={null}
className={cn(
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden border-b border-black/10 pl-3 pr-9 text-gray-900 last:border-0 hover:bg-gray-20 dark:border-white/20 dark:text-white dark:hover:bg-gray-700',
optionsClass ?? '',
)}
>
<span className="flex items-center gap-1.5 truncate">
<span
className={cn(
'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100',
option === value ? 'font-semibold' : '',
)}
>
{option}
</span>
{option === value && (
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-800 dark:text-gray-100">
<CheckMark />
</span>
)}
</span>
{renderOption()}
</Listbox.Option>
))}
)}
{availableValues.map((option: string | Option, i: number) => {
if (!option) {
return null;
}
const currentLabel = typeof option === 'string' ? option : option?.label ?? '';
const currentValue = typeof option === 'string' ? option : option?.value ?? '';
let activeValue: string | number | null | Option = value;
if (typeof activeValue !== 'string') {
activeValue = activeValue?.value ?? '';
}
return (
<Listbox.Option
key={i}
value={currentValue}
className={cn(
'group relative flex h-[42px] cursor-pointer select-none items-center overflow-hidden border-b border-black/10 pl-3 pr-9 text-gray-900 last:border-0 hover:bg-gray-20 dark:border-white/20 dark:text-white dark:hover:bg-gray-700',
optionsClass ?? '',
)}
>
<span className="flex items-center gap-1.5 truncate">
<span
className={cn(
'flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100',
option === value ? 'font-semibold' : '',
iconSide === 'left' ? 'ml-4' : '',
)}
>
{currentLabel}
</span>
{currentValue === activeValue && (
<span
className={cn(
'absolute inset-y-0 flex items-center text-gray-800 dark:text-gray-100',
iconSide === 'left' ? 'left-0 pl-2' : 'right-0 pr-3',
)}
>
<CheckMark />
</span>
)}
</span>
</Listbox.Option>
);
})}
</Listbox.Options>
</Transition>
</>

View file

@ -0,0 +1,130 @@
import React from 'react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import MenuItem from '~/components/Chat/Menus/UI/MenuItem';
import type { Option } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils/';
type SelectDropDownProps = {
id?: string;
title?: string;
value: string | null | Option;
disabled?: boolean;
setValue: (value: string) => void;
availableValues: string[] | Option[];
emptyTitle?: boolean;
showAbove?: boolean;
showLabel?: boolean;
iconSide?: 'left' | 'right';
renderOption?: () => React.ReactNode;
containerClassName?: string;
currentValueClass?: string;
optionsListClass?: string;
optionsClass?: string;
subContainerClassName?: string;
className?: string;
};
function SelectDropDownPop({
title: _title,
value,
availableValues,
setValue,
showAbove = false,
showLabel = true,
emptyTitle = false,
containerClassName,
currentValueClass,
subContainerClassName,
className,
}: SelectDropDownProps) {
const localize = useLocalize();
const transitionProps = { className: 'top-full mt-3' };
if (showAbove) {
transitionProps.className = 'bottom-full mb-3';
}
let title = _title;
if (emptyTitle) {
title = '';
} else if (!title) {
title = localize('com_ui_model');
}
return (
<Root>
<div className={cn('flex items-center justify-center gap-2 ', containerClassName ?? '')}>
<div className={cn('relative w-full', subContainerClassName ?? '')}>
<Trigger asChild>
<button
data-testid="select-dropdown-button"
className={cn(
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-white/20 dark:bg-gray-800 sm:text-sm',
className ?? '',
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-black/10 dark:radix-state-open:bg-black/20',
)}
>
{' '}
{showLabel && (
<label className="block text-xs text-gray-700 dark:text-gray-500 ">{title}</label>
)}
<span className="inline-flex w-full truncate">
<span
className={cn(
'flex h-6 items-center gap-1 truncate text-sm text-gray-900 dark:text-white',
!showLabel ? 'text-xs' : '',
currentValueClass ?? '',
)}
>
{/* {!showLabel && !emptyTitle && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)} */}
{typeof value !== 'string' && value ? value?.label ?? '' : value ?? ''}
</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-gray-400"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
style={showAbove ? { transform: 'scaleY(-1)' } : {}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</span>
</button>
</Trigger>
<Portal>
<Content
side="bottom"
align="start"
className="mt-2 max-h-60 min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white"
>
{availableValues.map((option) => {
return (
<MenuItem
key={option}
title={option}
value={option}
selected={!!(value && value === option)}
onClick={() => setValue(option)}
></MenuItem>
);
})}
</Content>
</Portal>
</div>
</div>
</Root>
);
}
export default SelectDropDownPop;

View file

@ -0,0 +1,24 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '~/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className = '', orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View file

@ -16,5 +16,9 @@ export * from './Templates';
export * from './Textarea';
export * from './Tooltip';
export { default as Dropdown } from './Dropdown';
export { default as FileUpload } from './FileUpload';
export { default as DelayedRender } from './DelayedRender';
export { default as SelectDropDown } from './SelectDropDown';
export { default as MultiSelectPop } from './MultiSelectPop';
export { default as SelectDropDownPop } from './SelectDropDownPop';
export { default as MultiSelectDropDown } from './MultiSelectDropDown';