📧 feat: Mention "@" Command Popover (#2635)

* feat: initial mockup

* wip: activesetting, may use or not use

* wip: mention with useCombobox usage

* feat: connect textarea to new mention popover

* refactor: consolidate icon logic for Landing/convos

* refactor: cleanup URL logic

* refactor(useTextarea): key up handler

* wip: render desired mention options

* refactor: improve mention detection

* feat: modular chat the default option

* WIP: first pass mention selection

* feat: scroll mention items with keypad

* chore(showMentionPopoverFamily): add typing to atomFamily

* feat: removeAtSymbol

* refactor(useListAssistantsQuery): use defaultOrderQuery as default param

* feat: assistants mentioning

* fix conversation switch errors

* filter mention selections based on startup settings and available endpoints

* fix: mentions model spec icon URL

* style: archive icon

* fix: convo renaming behavior on click

* fix(Convo): toggle hover state

* style: EditMenu refactor

* fix: archive chats table

* fix: errorsToString import

* chore: remove comments

* chore: remove comment

* feat: mention descriptions

* refactor: make sure continue hover button is always last, add correct fork button alt text
This commit is contained in:
Danny Avila 2024-05-07 13:13:55 -04:00 committed by GitHub
parent 89b1e33be0
commit b6d6343f54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1048 additions and 217 deletions

View file

@ -1,6 +1,7 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { registerSchema, errorsToString } = require('~/strategies/validators');
const { errorsToString } = require('librechat-data-provider');
const { registerSchema } = require('~/strategies/validators');
const isDomainAllowed = require('./isDomainAllowed');
const Token = require('~/models/schema/tokenSchema');
const { sendEmail } = require('~/server/utils');

View file

@ -324,6 +324,7 @@ export type Option = Record<string, unknown> & {
};
export type OptionWithIcon = Option & { icon?: React.ReactNode };
export type MentionOption = OptionWithIcon & { type: string; value: string; description?: string };
export type TOptionSettings = {
showExamples?: boolean;

View file

@ -0,0 +1,8 @@
export default function ActiveSetting() {
return (
<div className="text-token-text-tertiary space-x-2 overflow-hidden text-ellipsis text-sm font-light">
Talking to{' '}
<span className="text-token-text-secondary font-medium">[latest] Tailwind CSS GPT</span>
</div>
);
}

View file

@ -17,19 +17,24 @@ import { mainTextareaId } from '~/common';
import StopButton from './StopButton';
import SendButton from './SendButton';
import FileRow from './Files/FileRow';
import Mention from './Mention';
import store from '~/store';
const ChatForm = ({ index = 0 }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const { requiresKey } = useRequiresKey();
const methods = useForm<{ text: string }>({
defaultValues: { text: '' },
});
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
useTextarea({
textAreaRef,
submitButtonRef,
disabled: !!requiresKey,
@ -92,6 +97,9 @@ const ChatForm = ({ index = 0 }) => {
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
{showMentionPopover && (
<Mention setShowMentionPopover={setShowMentionPopover} textAreaRef={textAreaRef} />
)}
<div className="[&:has(textarea:focus)]:border-token-border-xheavy border-token-border-medium bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
<FileRow
files={files}
@ -114,6 +122,7 @@ const ChatForm = ({ index = 0 }) => {
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}

View file

@ -0,0 +1,148 @@
import { useState, useRef, useEffect } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { MentionOption } from '~/common';
import { useAssistantsMapContext } from '~/Providers';
import useMentions from '~/hooks/Input/useMentions';
import { useLocalize, useCombobox } from '~/hooks';
import { removeAtSymbolIfLast } from '~/utils';
import MentionItem from './MentionItem';
export default function Mention({
setShowMentionPopover,
textAreaRef,
}: {
setShowMentionPopover: SetterOrUpdater<boolean>;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const { options, modelsConfig, assistants, onSelectMention } = useMentions({ assistantMap });
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [inputOptions, setInputOptions] = useState<MentionOption[]>(options);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '',
options: inputOptions,
});
const handleSelect = (mention?: MentionOption) => {
if (!mention) {
return;
}
const defaultSelect = () => {
setSearchValue('');
setOpen(false);
setShowMentionPopover(false);
onSelectMention(mention);
if (textAreaRef.current) {
removeAtSymbolIfLast(textAreaRef.current);
}
};
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) {
setSearchValue('');
setInputOptions(assistants);
setActiveIndex(0);
inputRef.current?.focus();
} else if (mention.type === 'endpoint') {
const models = (modelsConfig?.[mention.value ?? ''] ?? []).map((model) => ({
value: mention.value,
label: model,
type: 'model',
}));
setActiveIndex(0);
setSearchValue('');
setInputOptions(models);
inputRef.current?.focus();
} else {
defaultSelect();
}
};
useEffect(() => {
if (!open) {
setInputOptions(options);
setActiveIndex(0);
}
}, [open, options]);
useEffect(() => {
const currentActiveItem = document.getElementById(`mention-item-${activeIndex}`);
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [activeIndex]);
return (
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
<input
autoFocus
ref={inputRef}
placeholder={localize('com_ui_mention')}
className="mb-1 w-full border-0 bg-white p-2 text-sm focus:outline-none dark:bg-gray-700 dark:text-gray-200"
autoComplete="off"
value={searchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setShowMentionPopover(false);
textAreaRef.current?.focus();
}
if (e.key === 'ArrowDown') {
setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
const mentionOption = matches[0] as MentionOption | undefined;
if (mentionOption?.type === 'endpoint') {
e.preventDefault();
} else if (e.key === 'Enter') {
e.preventDefault();
}
handleSelect(matches[activeIndex] as MentionOption);
} else if (e.key === 'Backspace' && searchValue === '') {
setOpen(false);
setShowMentionPopover(false);
textAreaRef.current?.focus();
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
setShowMentionPopover(false);
}, 150);
}}
/>
{open && (
<div className="max-h-40 overflow-y-auto">
{(matches as MentionOption[]).map((mention, index) => (
<MentionItem
index={index}
key={`${mention.value}-${index}`}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { Clock4 } from 'lucide-react';
import { cn } from '~/utils';
export default function MentionItem({
name,
onClick,
index,
icon,
isActive,
description,
}: {
name: string;
onClick: () => void;
index: number;
icon?: React.ReactNode;
isActive?: boolean;
description?: string;
}) {
return (
<div tabIndex={index} onClick={onClick} id={`mention-item-${index}`} className="cursor-pointer">
<div
className={cn(
'hover:bg-token-main-surface-secondary text-token-text-primary bg-token-main-surface-secondary group flex h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium dark:hover:bg-gray-600',
index === 0 ? 'dark:bg-gray-600' : '',
isActive ? 'dark:bg-gray-600' : '',
)}
>
{icon ? icon : null}
<div className="flex h-fit grow flex-row justify-between space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
<div className="flex flex-row space-x-2">
<span className="shrink-0 truncate">{name}</span>
{description ? (
<span className="text-token-text-tertiary flex-grow truncate text-sm font-light sm:max-w-xs lg:max-w-md">
{description}
</span>
) : null}
</div>
<span className="shrink-0 self-center">
<Clock4 size={16} className="icon-sm" />
</span>
</div>
</div>
</div>
);
}

View file

@ -2,11 +2,10 @@ import { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { ReactNode } from 'react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { icons } from './Menus/Endpoints/Icons';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { BirthdayIcon } from '~/components/svg';
import { getIconEndpoint, cn } from '~/utils';
import { useLocalize } from '~/hooks';
export default function Landing({ Header }: { Header?: ReactNode }) {
@ -31,52 +30,35 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
const iconURL = conversation?.iconURL;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
const Icon = icons[iconKey];
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const assistantDesc = (assistant && assistant?.description) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
let className =
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
if (assistantName && avatar) {
className = 'shadow-stroke overflow-hidden rounded-full';
}
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<div className="relative h-full">
<div className="absolute left-0 right-0">{Header && Header}</div>
<div className="flex h-full flex-col items-center justify-center">
<div className="relative mb-3 h-[72px] w-[72px]">
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL
preset={conversation}
endpointIconURL={endpointIconURL}
assistantName={assistantName}
assistantAvatar={avatar}
context="landing"
/>
) : (
<div className={className}>
{endpoint &&
Icon &&
Icon({
size: 41,
context: 'landing',
className: 'h-2/3 w-2/3',
iconURL: endpointIconURL,
assistantName,
endpoint,
avatar,
})}
</div>
<div
className={cn(
'relative h-[72px] w-[72px]',
assistantName && avatar ? 'mb-0' : 'mb-3',
)}
>
<ConvoIcon
conversation={conversation}
assistantMap={assistantMap}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
size={41}
/>
<TooltipTrigger>
{(startupConfig?.showBirthdayIcon ?? false) && (
<BirthdayIcon className="absolute bottom-12 right-5" />

View file

@ -29,7 +29,7 @@ export const icons = {
return (
<img
src={avatar}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full"
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
alt={assistantName}
width="80"
height="80"

View file

@ -102,6 +102,13 @@ export default function HoverButtons({
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
<Fork
isLast={isLast}
messageId={message.messageId}
conversationId={conversation.conversationId}
forkingSupported={forkingSupported}
latestMessage={latestMessage}
/>
{continueSupported ? (
<button
className={cn(
@ -115,13 +122,6 @@ export default function HoverButtons({
<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}
<Fork
isLast={isLast}
messageId={message.messageId}
conversationId={conversation.conversationId}
forkingSupported={forkingSupported}
latestMessage={latestMessage}
/>
</div>
);
}

View file

@ -59,7 +59,7 @@ export default function ArchiveButton({
);
};
const classProp: { className?: string } = {
className: 'p-1 hover:text-black dark:hover:text-white',
className: 'z-50 hover:text-black dark:hover:text-white',
};
if (twcss) {
classProp.className = twcss;
@ -69,7 +69,7 @@ export default function ArchiveButton({
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<span>{icon}</span>
<span className="h-5 w-5">{icon}</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
{localize(`com_ui_${label}`)}

View file

@ -4,18 +4,19 @@ import { useState, useRef, useMemo } from 'react';
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import { MinimalIcon, ConvoIconURL } from '~/components/Endpoints';
import { useUpdateConversationMutation } from '~/data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { useConversations, useNavigateToConvo } from '~/hooks';
import { getEndpointField, getIconEndpoint } from '~/utils';
import { NotificationSeverity } from '~/common';
import { ArchiveIcon } from '~/components/svg';
import { useToastContext } from '~/Providers';
import DeleteButton from './DeleteButton';
import RenameButton from './RenameButton';
import store from '~/store';
import EditMenuButton from './EditMenuButton';
import ArchiveButton from './ArchiveButton';
import { Archive } from 'lucide-react';
import DeleteButton from './DeleteButton';
import RenameButton from './RenameButton';
import HoverToggle from './HoverToggle';
import { cn } from '~/utils';
import store from '~/store';
type KeyEvent = KeyboardEvent<HTMLInputElement>;
@ -102,128 +103,91 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
);
};
const iconURL = conversation.iconURL ?? '';
let endpoint = conversation.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
let icon: React.ReactNode | null = null;
if (iconURL && iconURL.includes('http')) {
icon = ConvoIconURL({
preset: conversation,
context: 'menu-item',
endpointIconURL,
});
} else {
icon = MinimalIcon({
size: 20,
iconURL: endpointIconURL,
endpoint,
endpointType,
model: conversation.model,
error: false,
className: 'mr-0',
isCreatedByUser: false,
chatGptLabel: undefined,
modelLabel: undefined,
jailbreak: undefined,
});
}
const handleKeyDown = (e: KeyEvent) => {
if (e.key === 'Enter') {
if (e.key === 'Escape') {
setTitleInput(title);
setRenaming(false);
} else if (e.key === 'Enter') {
onRename(e);
}
};
const activeConvo =
const isActiveConvo =
currentConvoId === conversationId ||
(isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new');
const aProps = {
className:
'group relative rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-2 break-all rounded-lg bg-gray-200 dark:bg-gray-700 py-2 px-2',
};
if (!activeConvo) {
aProps.className =
'group relative grow overflow-hidden whitespace-nowrap rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-2 break-all rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 py-2 px-2';
}
return (
<a
href={`/c/${conversationId}`}
data-testid="convo-item"
onClick={clickHandler}
{...aProps}
title={title}
>
{icon}
<div className="relative line-clamp-1 max-h-5 flex-1 grow overflow-hidden">
{renaming === true ? (
<div className="hover:bg-token-sidebar-surface-secondary group relative rounded-lg active:opacity-90">
{renaming ? (
<div className="absolute bottom-0 left-0 right-0 top-0 z-50 flex w-full items-center rounded-lg bg-gray-200 dark:bg-gray-700">
<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"
className="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={`absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l ${
!renaming ? 'from-gray-200 from-60% to-transparent dark:from-gray-700' : ''
}`}
></div>
) : (
<div className="absolute bottom-0 right-0 top-0 w-2 bg-gradient-to-l from-0% to-transparent group-hover:w-1 group-hover:from-60%"></div>
)}
{activeConvo ? (
<div className="visible absolute right-1 z-10 flex items-center from-gray-900 text-gray-500 dark:text-gray-300">
{!renaming && (
<HoverToggle isActiveConvo={isActiveConvo}>
<EditMenuButton>
<div className="flex flex-col gap-4 p-3">
<div className="flex items-center gap-2">
<RenameButton
renaming={renaming}
onRename={onRename}
renameHandler={renameHandler}
twcss="flex items-center gap-2"
appendLabel={true}
/>
</div>
<div className="flex items-center gap-2 text-red-500">
<DeleteButton
conversationId={conversationId}
retainView={retainView}
renaming={renaming}
title={title}
twcss="flex items-center gap-2"
appendLabel={true}
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
/>
</div>
</div>
</EditMenuButton>
)}
{!renaming && (
<ArchiveButton
conversationId={conversationId}
retainView={retainView}
shouldArchive={true}
icon={<Archive className="h-5 w-5 hover:text-gray-400" />}
icon={<ArchiveIcon className="w-full hover:text-gray-400" />}
/>
</HoverToggle>
)}
</div>
<a
href={`/c/${conversationId}`}
data-testid="convo-item"
onClick={clickHandler}
className={cn(
isActiveConvo
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-800',
!isActiveConvo && !renaming ? 'peer-hover:bg-gray-200 dark:peer-hover:bg-gray-800' : '',
)}
title={title}
>
<EndpointIcon
conversation={conversation}
endpointsConfig={endpointsConfig}
size={20}
context="menu-item"
/>
{!renaming && (
<div className="relative line-clamp-1 max-h-5 flex-1 grow overflow-hidden">{title}</div>
)}
{isActiveConvo ? (
<div
className={cn(
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
!renaming ? 'from-gray-200 from-60% to-transparent dark:from-gray-700' : '',
)}
/>
) : (
<div className="absolute bottom-0 right-0 top-0 w-14 rounded-lg bg-gradient-to-l from-gray-50 from-0% to-transparent group-hover:from-gray-200 dark:from-gray-750 dark:group-hover:from-gray-800" />
<div className="absolute bottom-0 right-0 top-0 w-2 bg-gradient-to-l from-0% to-transparent group-hover:w-1 group-hover:from-60%" />
)}
</a>
</div>
);
}

View file

@ -22,8 +22,8 @@ export default function DeleteButton({
renaming,
retainView,
title,
twcss,
appendLabel = false,
className = '',
}) {
const localize = useLocalize();
const queryClient = useQueryClient();
@ -45,13 +45,6 @@ export default function DeleteButton({
deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' });
}, [conversationId, deleteConvoMutation, queryClient]);
const classProp: { className?: string } = {
className: 'p-1 hover:text-black dark:hover:text-white',
};
if (twcss) {
classProp.className = twcss;
}
const renderDeleteButton = () => {
if (appendLabel) {
return (
@ -79,7 +72,7 @@ export default function DeleteButton({
return (
<Dialog>
<DialogTrigger asChild>
<button {...classProp}>{renaming ? <CrossIcon /> : renderDeleteButton()}</button>
<button className={className}>{renaming ? <CrossIcon /> : renderDeleteButton()}</button>
</DialogTrigger>
<DialogTemplate
showCloseButton={false}

View file

@ -1,34 +1,36 @@
import type { FC } from 'react';
import { DotsIcon } from '~/components/svg';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { useToggle } from './ToggleContext';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
type EditMenuButtonProps = {
children: React.ReactNode;
};
const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonProps) => {
const localize = useLocalize();
const { setPopoverActive } = useToggle();
return (
<Root>
<Root onOpenChange={(open) => setPopoverActive(open)}>
<Trigger asChild>
<div
className={cn(
'pointer-cursor relative flex flex-col text-left focus:outline-none focus:ring-0 focus:ring-offset-0 sm:text-sm',
'hover:text-gray-400 radix-state-open:text-gray-400 dark:hover:text-gray-400 dark:radix-state-open:text-gray-400',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center pr-2 focus:ring-0 focus:ring-offset-0',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center focus:ring-0 focus:ring-offset-0',
)}
id="edit-menu-button"
data-testid="edit-menu-button"
title={localize('com_endpoint_examples')}
title={localize('com_ui_more_options')}
>
<TooltipProvider delayDuration={250}>
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="">
<DotsIcon className="h-4 w-4 flex-shrink-0 text-gray-500 hover:text-gray-400 dark:text-gray-300 dark:hover:text-gray-400" />
<DotsIcon className="h-[18px] w-[18px] flex-shrink-0 text-gray-500 hover:text-gray-400 dark:text-gray-300 dark:hover:text-gray-400" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
@ -42,7 +44,11 @@ const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonPro
<Content
side="bottom"
align="start"
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white md:min-w-[200px]"
className={cn(
'popover radix-side-bottom:animate-slideUpAndFade radix-side-left:animate-slideRightAndFade radix-side-right:animate-slideLeftAndFade radix-side-top:animate-slideDownAndFade overflow-hidden rounded-lg shadow-lg',
'border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-700 dark:text-white',
'flex min-w-[200px] max-w-xs flex-wrap',
)}
>
{children}
</Content>

View file

@ -182,7 +182,7 @@ export default function Fork({
}
}}
type="button"
title={localize('com_ui_continue')}
title={localize('com_ui_fork')}
>
<GitFork className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>

View file

@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { ToggleContext } from './ToggleContext';
import { cn } from '~/utils';
const HoverToggle = ({
children,
isActiveConvo,
}: {
children: React.ReactNode;
isActiveConvo: boolean;
}) => {
const [isPopoverActive, setIsPopoverActive] = useState(false);
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
return (
<ToggleContext.Provider value={{ setPopoverActive }}>
<div
className={cn(
'peer absolute bottom-0 right-0 top-0 items-center gap-1.5 rounded-r-lg from-gray-900 pl-2 pr-2 text-gray-500 dark:text-gray-300',
isPopoverActive || isActiveConvo ? 'flex' : 'hidden group-hover:flex',
isActiveConvo
? 'from-gray-50 from-85% to-transparent group-hover:bg-gradient-to-l group-hover:from-gray-200 dark:from-gray-750 dark:group-hover:from-gray-750'
: 'z-50 bg-gray-200 from-gray-50 from-0% to-transparent hover:bg-gray-200 hover:bg-gradient-to-l dark:bg-gray-800 dark:from-gray-750 dark:hover:bg-gray-800',
isPopoverActive && !isActiveConvo ? 'bg-gray-50 dark:bg-gray-750' : '',
)}
>
{children}
</div>
</ToggleContext.Provider>
);
};
export default HoverToggle;

View file

@ -6,7 +6,6 @@ interface RenameButtonProps {
renaming: boolean;
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
twcss?: string;
appendLabel?: boolean;
}
@ -14,19 +13,16 @@ export default function RenameButton({
renaming,
renameHandler,
onRename,
twcss,
appendLabel = false,
}: RenameButtonProps): ReactElement {
const localize = useLocalize();
const handler = renaming ? onRename : renameHandler;
const classProp: { className?: string } = {
className: 'p-1 hover:text-black dark:hover:text-white',
};
if (twcss) {
classProp.className = twcss;
}
return (
<button {...classProp} onClick={handler}>
<button
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
onClick={handler}
>
{renaming ? (
<CheckMark />
) : appendLabel ? (

View file

@ -0,0 +1,8 @@
import { createContext, useContext } from 'react';
const defaultFunction: (value: boolean) => void = () => ({});
export const ToggleContext = createContext({
setPopoverActive: defaultFunction,
});
export const useToggle = () => useContext(ToggleContext);

View file

@ -0,0 +1,62 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { Assistant, TConversation, TEndpointsConfig, TPreset } from 'librechat-data-provider';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconKey, getIconEndpoint } from '~/utils';
export default function ConvoIcon({
conversation,
endpointsConfig,
assistantMap,
className = '',
containerClassName = '',
context,
size,
}: {
conversation: TConversation | TPreset | null;
endpointsConfig: TEndpointsConfig;
assistantMap: Record<string, Assistant>;
containerClassName?: string;
context?: 'message' | 'nav' | 'landing' | 'menu-item';
className?: string;
size?: number;
}) {
const iconURL = conversation?.iconURL;
let endpoint = conversation?.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const assistant =
endpoint === EModelEndpoint.assistants && assistantMap?.[conversation?.assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
const Icon = icons[iconKey];
return (
<>
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL
preset={conversation}
endpointIconURL={endpointIconURL}
assistantName={assistantName}
assistantAvatar={avatar}
context={context}
/>
) : (
<div className={containerClassName}>
{endpoint &&
Icon &&
Icon({
size,
context,
className,
iconURL: endpointIconURL,
assistantName,
endpoint,
avatar,
})}
</div>
)}
</>
);
}

View file

@ -41,7 +41,9 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
},
) => React.JSX.Element;
if (!iconURL?.includes('http')) {
const isURL = iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'));
if (!isURL) {
Icon = icons[iconURL] ?? icons.unknown;
} else {
Icon = () => (

View file

@ -0,0 +1,65 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { Assistant, TConversation, TEndpointsConfig, TPreset } from 'librechat-data-provider';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { getEndpointField, getIconEndpoint } from '~/utils';
export default function EndpointIcon({
conversation,
endpointsConfig,
className = 'mr-0',
assistantMap,
context,
}: {
conversation: TConversation | TPreset | null;
endpointsConfig: TEndpointsConfig;
containerClassName?: string;
context?: 'message' | 'nav' | 'landing' | 'menu-item';
assistantMap?: Record<string, Assistant>;
className?: string;
size?: number;
}) {
const convoIconURL = conversation?.iconURL ?? '';
let endpoint = conversation?.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL: convoIconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const assistant =
endpoint === EModelEndpoint.assistants && assistantMap?.[conversation?.assistant_id ?? ''];
const assistantAvatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
const assistantName = (assistant && assistant?.name) || '';
const iconURL = assistantAvatar || convoIconURL;
let icon: React.ReactNode | null = null;
if (iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))) {
icon = ConvoIconURL({
preset: {
...(conversation as TPreset),
iconURL,
},
context,
endpointIconURL,
assistantAvatar,
assistantName,
});
} else {
icon = MinimalIcon({
size: 20,
iconURL: endpointIconURL,
endpoint,
endpointType,
model: conversation?.model,
error: false,
className,
isCreatedByUser: false,
chatGptLabel: undefined,
modelLabel: undefined,
jailbreak: undefined,
});
}
return icon;
}

View file

@ -1,5 +1,7 @@
export { default as Icon } from './Icon';
export { default as MinimalIcon } from './MinimalIcon';
export { default as ConvoIcon } from './ConvoIcon';
export { default as EndpointIcon } from './EndpointIcon';
export { default as ConvoIconURL } from './ConvoIconURL';
export { default as EndpointSettings } from './EndpointSettings';
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';

View file

@ -1,11 +1,11 @@
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import { MessageCircle, ArchiveRestore, Archive } from 'lucide-react';
import { useMemo, useState } from 'react';
import { MessageCircle, ArchiveRestore } from 'lucide-react';
import { useConversationsInfiniteQuery } from '~/data-provider';
import DeleteButton from '~/components/Conversations/DeleteButton';
import { cn } from '~/utils';
import { Spinner } from '~/components';
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
import ArchiveButton from '~/components/Conversations/ArchiveButton';
import DeleteButton from '~/components/Conversations/DeleteButton';
import { Spinner } from '~/components/svg';
import { cn } from '~/utils';
export default function ArchivedChatsTable({ className }: { className?: string }) {
const localize = useLocalize();
@ -44,7 +44,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
<div
className={cn(
'grid w-full gap-2',
'-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
'flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500',
'max-h-[350px]',
)}
ref={containerRef}
@ -66,7 +66,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
<MessageCircle className="mr-1 h-5 w-5" />
{conversation.title}
</td>
<td className="p-3">
<td className="p-1">
<div className="flex justify-between">
<div className="flex justify-start dark:text-gray-200">
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
@ -75,7 +75,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
year: 'numeric',
})}
</div>
<div className="flex items-center justify-end gap-1 text-gray-400">
<div className="ml-auto mr-4 flex items-center justify-end gap-1 text-gray-400">
{conversation.conversationId && (
<>
<ArchiveButton
@ -84,15 +84,14 @@ export default function ArchivedChatsTable({ className }: { className?: string }
shouldArchive={false}
icon={<ArchiveRestore className="h-4 w-4 hover:text-gray-300" />}
/>
<div className="h-4 w-4 hover:text-gray-300">
<DeleteButton
conversationId={conversation.conversationId}
retainView={moveToTop}
renaming={false}
title={conversation.title}
twcss="flex items-center gap-2"
appendLabel={false}
className="mx-3 flex items-center"
/>
</div>
</>

View file

@ -0,0 +1,19 @@
export default function ArchiveIcon({ className = 'icon-md' }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.62188 3.07918C3.87597 2.571 4.39537 2.25 4.96353 2.25H13.0365C13.6046 2.25 14.124 2.571 14.3781 3.07918L15.75 5.82295V13.5C15.75 14.7426 14.7426 15.75 13.5 15.75H4.5C3.25736 15.75 2.25 14.7426 2.25 13.5V5.82295L3.62188 3.07918ZM13.0365 3.75H4.96353L4.21353 5.25H13.7865L13.0365 3.75ZM14.25 6.75H3.75V13.5C3.75 13.9142 4.08579 14.25 4.5 14.25H13.5C13.9142 14.25 14.25 13.9142 14.25 13.5V6.75ZM6.75 9C6.75 8.58579 7.08579 8.25 7.5 8.25H10.5C10.9142 8.25 11.25 8.58579 11.25 9C11.25 9.41421 10.9142 9.75 10.5 9.75H7.5C7.08579 9.75 6.75 9.41421 6.75 9Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -1,3 +1,4 @@
export { default as ArchiveIcon } from './ArchiveIcon';
export { default as Blocks } from './Blocks';
export { default as Plugin } from './Plugin';
export { default as GPTIcon } from './GPTIcon';

View file

@ -1,4 +1,4 @@
import { EModelEndpoint, QueryKeys, dataService } from 'librechat-data-provider';
import { EModelEndpoint, QueryKeys, dataService, defaultOrderQuery } from 'librechat-data-provider';
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type {
UseInfiniteQueryOptions,
@ -187,7 +187,7 @@ export const useAvailableToolsQuery = (): QueryObserverResult<TPlugin[]> => {
* Hook for listing all assistants, with optional parameters provided for pagination and sorting
*/
export const useListAssistantsQuery = <TData = AssistantListResponse>(
params?: AssistantListParams,
params: AssistantListParams = defaultOrderQuery,
config?: UseQueryOptions<AssistantListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();

View file

@ -1,13 +1,13 @@
import { useMemo, useState } from 'react';
import { matchSorter } from 'match-sorter';
import type { OptionWithIcon } from '~/common';
import type { OptionWithIcon, MentionOption } from '~/common';
export default function useCombobox({
value,
options,
}: {
value: string;
options: OptionWithIcon[];
options: Array<OptionWithIcon | MentionOption>;
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');

View file

@ -0,0 +1,118 @@
import { useMemo } from 'react';
import {
useGetModelsQuery,
useGetStartupConfig,
useGetEndpointsQuery,
} from 'librechat-data-provider/react-query';
import { getConfigDefaults, EModelEndpoint, alternateName } from 'librechat-data-provider';
import type { Assistant } from 'librechat-data-provider';
import { useGetPresetsQuery, useListAssistantsQuery } from '~/data-provider';
import { mapEndpoints, getPresetTitle } from '~/utils';
import { EndpointIcon } from '~/components/Endpoints';
import useSelectMention from './useSelectMention';
const defaultInterface = getConfigDefaults().interface;
export default function useMentions({ assistantMap }: { assistantMap: Record<string, Assistant> }) {
const { data: presets } = useGetPresetsQuery();
const { data: modelsConfig } = useGetModelsQuery();
const { data: startupConfig } = useGetStartupConfig();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: endpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
const { data: assistants = [] } = useListAssistantsQuery(undefined, {
select: (res) =>
res.data
.map(({ id, name, description }) => ({
type: 'assistant',
label: name ?? '',
value: id,
description: description ?? '',
icon: EndpointIcon({
conversation: { assistant_id: id, endpoint: EModelEndpoint.assistants },
containerClassName: 'shadow-stroke overflow-hidden rounded-full',
endpointsConfig: endpointsConfig,
context: 'menu-item',
assistantMap,
size: 20,
}),
}))
.filter(Boolean),
});
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const { onSelectMention } = useSelectMention({
modelSpecs,
endpointsConfig,
presets,
assistantMap,
});
const options = useMemo(() => {
const mentions = [
...(modelSpecs?.length > 0 ? modelSpecs : []).map((modelSpec) => ({
value: modelSpec.name,
label: modelSpec.label,
description: modelSpec.description,
icon: EndpointIcon({
conversation: {
...modelSpec.preset,
iconURL: modelSpec.iconURL,
},
endpointsConfig,
context: 'menu-item',
size: 20,
}),
type: 'modelSpec',
})),
...(interfaceConfig.endpointsMenu ? endpoints : []).map((endpoint) => ({
value: endpoint,
label: alternateName[endpoint] ?? endpoint ?? '',
type: 'endpoint',
icon: EndpointIcon({
conversation: { endpoint },
endpointsConfig,
context: 'menu-item',
size: 20,
}),
})),
...(endpointsConfig?.[EModelEndpoint.assistants] ? assistants : []),
...((interfaceConfig.presets ? presets : [])?.map((preset, index) => ({
value: preset.presetId ?? `preset-${index}`,
label: preset.title ?? preset.modelLabel ?? preset.chatGptLabel ?? '',
description: getPresetTitle(preset, true),
icon: EndpointIcon({
conversation: preset,
containerClassName: 'shadow-stroke overflow-hidden rounded-full',
endpointsConfig: endpointsConfig,
context: 'menu-item',
assistantMap,
size: 20,
}),
type: 'preset',
})) ?? []),
];
return mentions;
}, [
presets,
endpoints,
modelSpecs,
assistants,
assistantMap,
endpointsConfig,
interfaceConfig.presets,
interfaceConfig.endpointsMenu,
]);
return {
options,
assistants,
modelsConfig,
onSelectMention,
};
}

View file

@ -0,0 +1,210 @@
import { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { EModelEndpoint } from 'librechat-data-provider';
import type {
TModelSpec,
TConversation,
TEndpointsConfig,
TPreset,
Assistant,
} from 'librechat-data-provider';
import type { MentionOption } from '~/common';
import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools } from '~/utils';
import { useDefaultConvo, useNewConvo } from '~/hooks';
import { useChatContext } from '~/Providers';
import store from '~/store';
export default function useSelectMention({
presets,
modelSpecs,
endpointsConfig,
assistantMap,
}: {
presets?: TPreset[];
modelSpecs: TModelSpec[];
endpointsConfig: TEndpointsConfig;
assistantMap: Record<string, Assistant>;
}) {
const { conversation } = useChatContext();
const { newConversation } = useNewConvo();
const getDefaultConversation = useDefaultConvo();
const modularChat = useRecoilValue(store.modularChat);
const availableTools = useRecoilValue(store.availableTools);
const onSelectSpec = useCallback(
(spec?: TModelSpec) => {
if (!spec) {
return;
}
const { preset } = spec;
preset.iconURL = getModelSpecIconURL(spec);
preset.spec = spec.name;
const { endpoint: newEndpoint } = preset;
if (!newEndpoint) {
return;
}
const {
shouldSwitch,
isNewModular,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
preset: template,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset, keepLatestMessage: true });
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) }, preset });
},
[conversation, getDefaultConversation, modularChat, newConversation, endpointsConfig],
);
type Kwargs = {
model?: string;
assistant_id?: string;
};
const onSelectEndpoint = useCallback(
(newEndpoint?: EModelEndpoint | string | null, kwargs: Kwargs = {}) => {
if (!newEndpoint) {
return;
}
const {
shouldSwitch,
isNewModular,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
if (kwargs.model) {
template.model = kwargs.model;
}
if (kwargs.assistant_id) {
template.assistant_id = kwargs.assistant_id;
}
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
template.endpointType = newEndpointType;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
preset: template,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
return;
}
newConversation({
template: { ...(template as Partial<TConversation>) },
preset: { ...kwargs, endpoint: newEndpoint },
});
},
[conversation, getDefaultConversation, modularChat, newConversation, endpointsConfig],
);
const onSelectPreset = useCallback(
(_newPreset?: TPreset) => {
if (!_newPreset) {
return;
}
const newPreset = removeUnavailableTools(_newPreset, availableTools);
const newEndpoint = newPreset.endpoint ?? '';
const {
shouldSwitch,
isNewModular,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
preset: template,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset: newPreset, keepLatestMessage: true });
return;
}
newConversation({ preset: newPreset });
},
[
availableTools,
conversation,
getDefaultConversation,
modularChat,
newConversation,
endpointsConfig,
],
);
const onSelectMention = useCallback(
(option: MentionOption) => {
const key = option.value;
if (option.type === 'preset') {
const preset = presets?.find((p) => p.presetId === key);
onSelectPreset(preset);
} else if (option.type === 'modelSpec') {
const modelSpec = modelSpecs.find((spec) => spec.name === key);
onSelectSpec(modelSpec);
} else if (option.type === 'model') {
onSelectEndpoint(key, { model: option.label });
} else if (option.type === 'endpoint') {
onSelectEndpoint(key);
} else if (option.type === 'assistant') {
onSelectEndpoint(EModelEndpoint.assistants, {
assistant_id: key,
model: assistantMap?.[key]?.model ?? '',
});
}
},
[modelSpecs, onSelectEndpoint, onSelectPreset, onSelectSpec, presets, assistantMap],
);
return {
onSelectMention,
};
}

View file

@ -1,7 +1,7 @@
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { useEffect, useRef, useCallback } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import React, { useEffect, useRef, useCallback } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type { TEndpointOption } from 'librechat-data-provider';
import type { KeyboardEvent } from 'react';
import { forceResize, insertTextAtCursor, getAssistantName } from '~/utils';
@ -23,20 +23,24 @@ export default function useTextarea({
submitButtonRef: React.RefObject<HTMLButtonElement>;
disabled?: boolean;
}) {
const assistantMap = useAssistantsMapContext();
const enterToSend = useRecoilValue(store.enterToSend);
const {
conversation,
isSubmitting,
latestMessage,
setShowBingToneSetting,
filesLoading,
setFilesLoading,
} = useChatContext();
const localize = useLocalize();
const getSender = useGetSender();
const isComposing = useRef(false);
const { handleFiles } = useFileHandling();
const getSender = useGetSender();
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const enterToSend = useRecoilValue(store.enterToSend);
const {
index,
conversation,
isSubmitting,
filesLoading,
latestMessage,
setFilesLoading,
setShowBingToneSetting,
} = useChatContext();
const setShowMentionPopover = useSetRecoilState(store.showMentionPopoverFamily(index));
const { conversationId, jailbreak, endpoint = '', assistant_id } = conversation || {};
const isNotAppendable =
@ -132,6 +136,30 @@ export default function useTextarea({
assistantMap,
]);
const handleKeyUp = useCallback(
(e: KeyEvent) => {
let isMention = false;
if (e.key === '@' || e.key === '2') {
const text = textAreaRef.current?.value;
isMention = !!(text && text[text.length - 1] === '@');
}
if (isMention) {
const startPos = textAreaRef.current?.selectionStart;
const isAtStart = startPos === 1;
const isPrecededBySpace =
startPos && textAreaRef.current?.value.charAt(startPos - 2) === ' ';
if (isAtStart || isPrecededBySpace) {
setShowMentionPopover(true);
} else {
setShowMentionPopover(false);
}
}
},
[textAreaRef, setShowMentionPopover],
);
const handleKeyDown = useCallback(
(e: KeyEvent) => {
if (e.key === 'Enter' && isSubmitting) {
@ -213,6 +241,7 @@ export default function useTextarea({
return {
textAreaRef,
handlePaste,
handleKeyUp,
handleKeyDown,
handleCompositionStart,
handleCompositionEnd,

View file

@ -125,6 +125,7 @@ export default {
com_user_message: 'You',
com_ui_copy_to_clipboard: 'Copy to clipboard',
com_ui_copied_to_clipboard: 'Copied to clipboard',
com_ui_fork: 'Fork',
com_ui_fork_info_1: 'Use this setting to fork messages with the desired behavior.',
com_ui_fork_info_2:
'"Forking" refers to creating a new conversation that start/end from specific messages in the current conversation, creating a copy according to the options selected.',
@ -154,6 +155,7 @@ export default {
com_ui_fork_branches: 'Include related branches',
com_ui_fork_visible: 'Visible messages only',
com_ui_fork_from_message: 'Select a fork option',
com_ui_mention: 'Mention an endpoint, assistant, or preset to quickly switch to it',
com_ui_regenerate: 'Regenerate',
com_ui_continue: 'Continue',
com_ui_edit: 'Edit',

View file

@ -182,3 +182,92 @@
.rotate {
animation: tuning 2.1s 1;
}
@-webkit-keyframes slideUpAndFade {
0% {
opacity:0;
-webkit-transform:translateY(2px);
transform:translateY(2px)
}
to {
opacity:1;
-webkit-transform:translateY(0);
transform:translateY(0)
}
}
@keyframes slideUpAndFade {
0% {
opacity:0;
-webkit-transform:translateY(2px);
transform:translateY(2px)
}
to {
opacity:1;
-webkit-transform:translateY(0);
transform:translateY(0)
}
}
.radix-side-bottom\:animate-slideUpAndFade[data-side=bottom] {
-webkit-animation:slideUpAndFade .4s cubic-bezier(.16,1,.3,1);
animation:slideUpAndFade .4s cubic-bezier(.16,1,.3,1)
}
@-webkit-keyframes slideRightAndFade {
0% {
opacity:0;
-webkit-transform:translateX(-2px);
transform:translateX(-2px)
}
to {
opacity:1;
-webkit-transform:translateX(0);
transform:translateX(0)
}
}
@keyframes slideRightAndFade {
0% {
opacity:0;
-webkit-transform:translateX(-2px);
transform:translateX(-2px)
}
to {
opacity:1;
-webkit-transform:translateX(0);
transform:translateX(0)
}
}
.radix-side-left\:animate-slideRightAndFade[data-side=left] {
-webkit-animation:slideRightAndFade .4s cubic-bezier(.16,1,.3,1);
animation:slideRightAndFade .4s cubic-bezier(.16,1,.3,1)
}
@keyframes slideLeftAndFade {
0% {
opacity:0;
-webkit-transform:translateX(2px);
transform:translateX(2px)
}
to {
opacity:1;
-webkit-transform:translateX(0);
transform:translateX(0)
}
}
.radix-side-right\:animate-slideLeftAndFade[data-side=right] {
-webkit-animation:slideLeftAndFade .4s cubic-bezier(.16,1,.3,1);
animation:slideLeftAndFade .4s cubic-bezier(.16,1,.3,1)
}
@keyframes slideDownAndFade {
0% {
opacity:0;
-webkit-transform:translateY(-2px);
transform:translateY(-2px)
}
to {
opacity:1;
-webkit-transform:translateY(0);
transform:translateY(0)
}
}
.radix-side-top\:animate-slideDownAndFade[data-side=top] {
-webkit-animation:slideDownAndFade .4s cubic-bezier(.16,1,.3,1);
animation:slideDownAndFade .4s cubic-bezier(.16,1,.3,1)
}

View file

@ -107,6 +107,11 @@ const showPopoverFamily = atomFamily({
default: false,
});
const showMentionPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showMentionPopoverByIndex',
default: false,
});
const latestMessageFamily = atomFamily<TMessage | null, string | number | null>({
key: 'latestMessageByIndex',
default: null,
@ -142,4 +147,5 @@ export default {
latestMessageFamily,
allConversationsSelector,
useCreateConversationAtom,
showMentionPopoverFamily,
};

View file

@ -101,7 +101,7 @@ const hideSidePanel = atom<boolean>({
const modularChat = atom<boolean>({
key: 'modularChat',
default: localStorage.getItem('modularChat') === 'true',
default: true,
effects: [
({ setSelf, onSet }) => {
const savedValue = localStorage.getItem('modularChat');

View file

@ -14,11 +14,13 @@ export const getPresetIcon = (preset: TPreset, Icon) => {
type TEndpoints = Array<string | EModelEndpoint>;
export const getPresetTitle = (preset: TPreset) => {
export const getPresetTitle = (preset: TPreset, mention?: boolean) => {
const {
endpoint,
title: presetTitle,
model,
tools,
promptPrefix,
chatGptLabel,
modelLabel,
jailbreak,
@ -51,6 +53,21 @@ export const getPresetTitle = (preset: TPreset) => {
title = presetTitle + ': ';
}
if (mention) {
return `${modelInfo}${label ? ` | ${label}` : ''}${promptPrefix ? ` | ${promptPrefix}` : ''}${
tools
? ` | ${tools
.map((tool: TPlugin | string) => {
if (typeof tool === 'string') {
return tool;
}
return tool.pluginKey;
})
.join(', ')}`
: ''
}`;
}
return `${title}${modelInfo}${label ? ` (${label})` : ''}`.trim();
};

View file

@ -57,3 +57,19 @@ export const trimUndoneRange = (textAreaRef: React.RefObject<HTMLTextAreaElement
textAreaRef.current.value = newValue;
textAreaRef.current.setSelectionRange(selectionStart, selectionStart);
};
/**
* Remove the "@" character from the end of the textarea's text if it's present.
* This function ensures that the "@" is only removed if it's the last character.
*
* @param {HTMLTextAreaElement} textarea - The textarea element where text manipulation will occur.
*/
export function removeAtSymbolIfLast(textarea: HTMLTextAreaElement) {
if (textarea.value.endsWith('@')) {
textarea.value = textarea.value.slice(0, -1);
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
textarea.focus();
}