mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
📧 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:
parent
89b1e33be0
commit
b6d6343f54
35 changed files with 1048 additions and 217 deletions
|
@ -1,6 +1,7 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const bcrypt = require('bcryptjs');
|
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 isDomainAllowed = require('./isDomainAllowed');
|
||||||
const Token = require('~/models/schema/tokenSchema');
|
const Token = require('~/models/schema/tokenSchema');
|
||||||
const { sendEmail } = require('~/server/utils');
|
const { sendEmail } = require('~/server/utils');
|
||||||
|
|
|
@ -324,6 +324,7 @@ export type Option = Record<string, unknown> & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OptionWithIcon = Option & { icon?: React.ReactNode };
|
export type OptionWithIcon = Option & { icon?: React.ReactNode };
|
||||||
|
export type MentionOption = OptionWithIcon & { type: string; value: string; description?: string };
|
||||||
|
|
||||||
export type TOptionSettings = {
|
export type TOptionSettings = {
|
||||||
showExamples?: boolean;
|
showExamples?: boolean;
|
||||||
|
|
8
client/src/components/Chat/Input/ActiveSetting.tsx
Normal file
8
client/src/components/Chat/Input/ActiveSetting.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -17,23 +17,28 @@ import { mainTextareaId } from '~/common';
|
||||||
import StopButton from './StopButton';
|
import StopButton from './StopButton';
|
||||||
import SendButton from './SendButton';
|
import SendButton from './SendButton';
|
||||||
import FileRow from './Files/FileRow';
|
import FileRow from './Files/FileRow';
|
||||||
|
import Mention from './Mention';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const ChatForm = ({ index = 0 }) => {
|
const ChatForm = ({ index = 0 }) => {
|
||||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
||||||
|
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
|
||||||
|
store.showMentionPopoverFamily(index),
|
||||||
|
);
|
||||||
const { requiresKey } = useRequiresKey();
|
const { requiresKey } = useRequiresKey();
|
||||||
|
|
||||||
const methods = useForm<{ text: string }>({
|
const methods = useForm<{ text: string }>({
|
||||||
defaultValues: { text: '' },
|
defaultValues: { text: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
|
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
|
||||||
textAreaRef,
|
useTextarea({
|
||||||
submitButtonRef,
|
textAreaRef,
|
||||||
disabled: !!requiresKey,
|
submitButtonRef,
|
||||||
});
|
disabled: !!requiresKey,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ask,
|
ask,
|
||||||
|
@ -92,6 +97,9 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
>
|
>
|
||||||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||||
<div className="flex w-full items-center">
|
<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">
|
<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
|
<FileRow
|
||||||
files={files}
|
files={files}
|
||||||
|
@ -114,6 +122,7 @@ const ChatForm = ({ index = 0 }) => {
|
||||||
disabled={disableInputs}
|
disabled={disableInputs}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
id={mainTextareaId}
|
id={mainTextareaId}
|
||||||
|
|
148
client/src/components/Chat/Input/Mention.tsx
Normal file
148
client/src/components/Chat/Input/Mention.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
46
client/src/components/Chat/Input/MentionItem.tsx
Normal file
46
client/src/components/Chat/Input/MentionItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,11 +2,10 @@ import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
||||||
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
|
|
||||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
|
||||||
import { icons } from './Menus/Endpoints/Icons';
|
|
||||||
import { BirthdayIcon } from '~/components/svg';
|
import { BirthdayIcon } from '~/components/svg';
|
||||||
|
import { getIconEndpoint, cn } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||||
|
@ -31,52 +30,35 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||||
const iconURL = conversation?.iconURL;
|
const iconURL = conversation?.iconURL;
|
||||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
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 assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
|
||||||
const assistantName = (assistant && assistant?.name) || '';
|
const assistantName = (assistant && assistant?.name) || '';
|
||||||
const assistantDesc = (assistant && assistant?.description) || '';
|
const assistantDesc = (assistant && assistant?.description) || '';
|
||||||
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
|
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';
|
'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 (
|
return (
|
||||||
<TooltipProvider delayDuration={50}>
|
<TooltipProvider delayDuration={50}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div className="absolute left-0 right-0">{Header && Header}</div>
|
<div className="absolute left-0 right-0">{Header && Header}</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
<div className="relative mb-3 h-[72px] w-[72px]">
|
<div
|
||||||
{iconURL && iconURL.includes('http') ? (
|
className={cn(
|
||||||
<ConvoIconURL
|
'relative h-[72px] w-[72px]',
|
||||||
preset={conversation}
|
assistantName && avatar ? 'mb-0' : 'mb-3',
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<ConvoIcon
|
||||||
|
conversation={conversation}
|
||||||
|
assistantMap={assistantMap}
|
||||||
|
endpointsConfig={endpointsConfig}
|
||||||
|
containerClassName={containerClassName}
|
||||||
|
context="landing"
|
||||||
|
className="h-2/3 w-2/3"
|
||||||
|
size={41}
|
||||||
|
/>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
{(startupConfig?.showBirthdayIcon ?? false) && (
|
{(startupConfig?.showBirthdayIcon ?? false) && (
|
||||||
<BirthdayIcon className="absolute bottom-12 right-5" />
|
<BirthdayIcon className="absolute bottom-12 right-5" />
|
||||||
|
|
|
@ -29,7 +29,7 @@ export const icons = {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={avatar}
|
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}
|
alt={assistantName}
|
||||||
width="80"
|
width="80"
|
||||||
height="80"
|
height="80"
|
||||||
|
|
|
@ -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" />
|
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Fork
|
||||||
|
isLast={isLast}
|
||||||
|
messageId={message.messageId}
|
||||||
|
conversationId={conversation.conversationId}
|
||||||
|
forkingSupported={forkingSupported}
|
||||||
|
latestMessage={latestMessage}
|
||||||
|
/>
|
||||||
{continueSupported ? (
|
{continueSupported ? (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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" />
|
<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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<Fork
|
|
||||||
isLast={isLast}
|
|
||||||
messageId={message.messageId}
|
|
||||||
conversationId={conversation.conversationId}
|
|
||||||
forkingSupported={forkingSupported}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ export default function ArchiveButton({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const classProp: { className?: string } = {
|
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) {
|
if (twcss) {
|
||||||
classProp.className = twcss;
|
classProp.className = twcss;
|
||||||
|
@ -69,7 +69,7 @@ export default function ArchiveButton({
|
||||||
<TooltipProvider delayDuration={250}>
|
<TooltipProvider delayDuration={250}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span>{icon}</span>
|
<span className="h-5 w-5">{icon}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" sideOffset={0}>
|
<TooltipContent side="top" sideOffset={0}>
|
||||||
{localize(`com_ui_${label}`)}
|
{localize(`com_ui_${label}`)}
|
||||||
|
|
|
@ -4,18 +4,19 @@ import { useState, useRef, useMemo } from 'react';
|
||||||
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||||
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
||||||
import { MinimalIcon, ConvoIconURL } from '~/components/Endpoints';
|
|
||||||
import { useUpdateConversationMutation } from '~/data-provider';
|
import { useUpdateConversationMutation } from '~/data-provider';
|
||||||
|
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||||
import { useConversations, useNavigateToConvo } from '~/hooks';
|
import { useConversations, useNavigateToConvo } from '~/hooks';
|
||||||
import { getEndpointField, getIconEndpoint } from '~/utils';
|
|
||||||
import { NotificationSeverity } from '~/common';
|
import { NotificationSeverity } from '~/common';
|
||||||
|
import { ArchiveIcon } from '~/components/svg';
|
||||||
import { useToastContext } from '~/Providers';
|
import { useToastContext } from '~/Providers';
|
||||||
import DeleteButton from './DeleteButton';
|
|
||||||
import RenameButton from './RenameButton';
|
|
||||||
import store from '~/store';
|
|
||||||
import EditMenuButton from './EditMenuButton';
|
import EditMenuButton from './EditMenuButton';
|
||||||
import ArchiveButton from './ArchiveButton';
|
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>;
|
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) => {
|
const handleKeyDown = (e: KeyEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Escape') {
|
||||||
|
setTitleInput(title);
|
||||||
|
setRenaming(false);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
onRename(e);
|
onRename(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeConvo =
|
const isActiveConvo =
|
||||||
currentConvoId === conversationId ||
|
currentConvoId === conversationId ||
|
||||||
(isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new');
|
(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 (
|
return (
|
||||||
<a
|
<div className="hover:bg-token-sidebar-surface-secondary group relative rounded-lg active:opacity-90">
|
||||||
href={`/c/${conversationId}`}
|
{renaming ? (
|
||||||
data-testid="convo-item"
|
<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">
|
||||||
onClick={clickHandler}
|
|
||||||
{...aProps}
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<div className="relative line-clamp-1 max-h-5 flex-1 grow overflow-hidden">
|
|
||||||
{renaming === true ? (
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
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}
|
value={titleInput}
|
||||||
onChange={(e) => setTitleInput(e.target.value)}
|
onChange={(e) => setTitleInput(e.target.value)}
|
||||||
onBlur={onRename}
|
onBlur={onRename}
|
||||||
onKeyDown={handleKeyDown}
|
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 && (
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</EditMenuButton>
|
|
||||||
)}
|
|
||||||
{!renaming && (
|
|
||||||
<ArchiveButton
|
|
||||||
conversationId={conversationId}
|
|
||||||
retainView={retainView}
|
|
||||||
shouldArchive={true}
|
|
||||||
icon={<Archive className="h-5 w-5 hover:text-gray-400" />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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" />
|
<HoverToggle isActiveConvo={isActiveConvo}>
|
||||||
|
<EditMenuButton>
|
||||||
|
<RenameButton
|
||||||
|
renaming={renaming}
|
||||||
|
onRename={onRename}
|
||||||
|
renameHandler={renameHandler}
|
||||||
|
appendLabel={true}
|
||||||
|
/>
|
||||||
|
<DeleteButton
|
||||||
|
conversationId={conversationId}
|
||||||
|
retainView={retainView}
|
||||||
|
renaming={renaming}
|
||||||
|
title={title}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</EditMenuButton>
|
||||||
|
<ArchiveButton
|
||||||
|
conversationId={conversationId}
|
||||||
|
retainView={retainView}
|
||||||
|
shouldArchive={true}
|
||||||
|
icon={<ArchiveIcon className="w-full hover:text-gray-400" />}
|
||||||
|
/>
|
||||||
|
</HoverToggle>
|
||||||
)}
|
)}
|
||||||
</a>
|
<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-2 bg-gradient-to-l from-0% to-transparent group-hover:w-1 group-hover:from-60%" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,8 +22,8 @@ export default function DeleteButton({
|
||||||
renaming,
|
renaming,
|
||||||
retainView,
|
retainView,
|
||||||
title,
|
title,
|
||||||
twcss,
|
|
||||||
appendLabel = false,
|
appendLabel = false,
|
||||||
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -45,13 +45,6 @@ export default function DeleteButton({
|
||||||
deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' });
|
deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' });
|
||||||
}, [conversationId, deleteConvoMutation, queryClient]);
|
}, [conversationId, deleteConvoMutation, queryClient]);
|
||||||
|
|
||||||
const classProp: { className?: string } = {
|
|
||||||
className: 'p-1 hover:text-black dark:hover:text-white',
|
|
||||||
};
|
|
||||||
if (twcss) {
|
|
||||||
classProp.className = twcss;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderDeleteButton = () => {
|
const renderDeleteButton = () => {
|
||||||
if (appendLabel) {
|
if (appendLabel) {
|
||||||
return (
|
return (
|
||||||
|
@ -79,7 +72,7 @@ export default function DeleteButton({
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button {...classProp}>{renaming ? <CrossIcon /> : renderDeleteButton()}</button>
|
<button className={className}>{renaming ? <CrossIcon /> : renderDeleteButton()}</button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogTemplate
|
<DialogTemplate
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
|
|
|
@ -1,34 +1,36 @@
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { DotsIcon } from '~/components/svg';
|
import { DotsIcon } from '~/components/svg';
|
||||||
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
|
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 { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
|
|
||||||
|
|
||||||
type EditMenuButtonProps = {
|
type EditMenuButtonProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonProps) => {
|
const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonProps) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const { setPopoverActive } = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Root>
|
<Root onOpenChange={(open) => setPopoverActive(open)}>
|
||||||
<Trigger asChild>
|
<Trigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-cursor relative flex flex-col text-left focus:outline-none focus:ring-0 focus:ring-offset-0 sm:text-sm',
|
'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',
|
'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"
|
id="edit-menu-button"
|
||||||
data-testid="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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button type="button" className="">
|
<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>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" sideOffset={0}>
|
<TooltipContent side="top" sideOffset={0}>
|
||||||
|
@ -42,7 +44,11 @@ const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonPro
|
||||||
<Content
|
<Content
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
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}
|
{children}
|
||||||
</Content>
|
</Content>
|
||||||
|
|
|
@ -182,7 +182,7 @@ export default function Fork({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
type="button"
|
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" />
|
<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>
|
</button>
|
||||||
|
|
32
client/src/components/Conversations/HoverToggle.tsx
Normal file
32
client/src/components/Conversations/HoverToggle.tsx
Normal 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;
|
|
@ -6,7 +6,6 @@ interface RenameButtonProps {
|
||||||
renaming: boolean;
|
renaming: boolean;
|
||||||
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
|
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
|
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
twcss?: string;
|
|
||||||
appendLabel?: boolean;
|
appendLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,19 +13,16 @@ export default function RenameButton({
|
||||||
renaming,
|
renaming,
|
||||||
renameHandler,
|
renameHandler,
|
||||||
onRename,
|
onRename,
|
||||||
twcss,
|
|
||||||
appendLabel = false,
|
appendLabel = false,
|
||||||
}: RenameButtonProps): ReactElement {
|
}: RenameButtonProps): ReactElement {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const handler = renaming ? onRename : renameHandler;
|
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 (
|
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 ? (
|
{renaming ? (
|
||||||
<CheckMark />
|
<CheckMark />
|
||||||
) : appendLabel ? (
|
) : appendLabel ? (
|
||||||
|
|
8
client/src/components/Conversations/ToggleContext.ts
Normal file
8
client/src/components/Conversations/ToggleContext.ts
Normal 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);
|
62
client/src/components/Endpoints/ConvoIcon.tsx
Normal file
62
client/src/components/Endpoints/ConvoIcon.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -41,7 +41,9 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
|
||||||
},
|
},
|
||||||
) => React.JSX.Element;
|
) => React.JSX.Element;
|
||||||
|
|
||||||
if (!iconURL?.includes('http')) {
|
const isURL = iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'));
|
||||||
|
|
||||||
|
if (!isURL) {
|
||||||
Icon = icons[iconURL] ?? icons.unknown;
|
Icon = icons[iconURL] ?? icons.unknown;
|
||||||
} else {
|
} else {
|
||||||
Icon = () => (
|
Icon = () => (
|
||||||
|
|
65
client/src/components/Endpoints/EndpointIcon.tsx
Normal file
65
client/src/components/Endpoints/EndpointIcon.tsx
Normal 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;
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
export { default as Icon } from './Icon';
|
export { default as Icon } from './Icon';
|
||||||
export { default as MinimalIcon } from './MinimalIcon';
|
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 ConvoIconURL } from './ConvoIconURL';
|
||||||
export { default as EndpointSettings } from './EndpointSettings';
|
export { default as EndpointSettings } from './EndpointSettings';
|
||||||
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';
|
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
|
|
||||||
import { MessageCircle, ArchiveRestore, Archive } from 'lucide-react';
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { MessageCircle, ArchiveRestore } from 'lucide-react';
|
||||||
import { useConversationsInfiniteQuery } from '~/data-provider';
|
import { useConversationsInfiniteQuery } from '~/data-provider';
|
||||||
import DeleteButton from '~/components/Conversations/DeleteButton';
|
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
|
||||||
import { Spinner } from '~/components';
|
|
||||||
import ArchiveButton from '~/components/Conversations/ArchiveButton';
|
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 }) {
|
export default function ArchivedChatsTable({ className }: { className?: string }) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
@ -44,7 +44,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid w-full gap-2',
|
'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]',
|
'max-h-[350px]',
|
||||||
)}
|
)}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
@ -66,7 +66,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
|
||||||
<MessageCircle className="mr-1 h-5 w-5" />
|
<MessageCircle className="mr-1 h-5 w-5" />
|
||||||
{conversation.title}
|
{conversation.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="flex justify-start dark:text-gray-200">
|
<div className="flex justify-start dark:text-gray-200">
|
||||||
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
|
{new Date(conversation.createdAt).toLocaleDateString('en-US', {
|
||||||
|
@ -75,7 +75,7 @@ export default function ArchivedChatsTable({ className }: { className?: string }
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{conversation.conversationId && (
|
||||||
<>
|
<>
|
||||||
<ArchiveButton
|
<ArchiveButton
|
||||||
|
@ -84,15 +84,14 @@ export default function ArchivedChatsTable({ className }: { className?: string }
|
||||||
shouldArchive={false}
|
shouldArchive={false}
|
||||||
icon={<ArchiveRestore className="h-4 w-4 hover:text-gray-300" />}
|
icon={<ArchiveRestore className="h-4 w-4 hover:text-gray-300" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="h-4 w-4 hover:text-gray-300">
|
<div className="h-4 w-4 hover:text-gray-300">
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
conversationId={conversation.conversationId}
|
conversationId={conversation.conversationId}
|
||||||
retainView={moveToTop}
|
retainView={moveToTop}
|
||||||
renaming={false}
|
renaming={false}
|
||||||
title={conversation.title}
|
title={conversation.title}
|
||||||
twcss="flex items-center gap-2"
|
|
||||||
appendLabel={false}
|
appendLabel={false}
|
||||||
|
className="mx-3 flex items-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
19
client/src/components/svg/ArchiveIcon.tsx
Normal file
19
client/src/components/svg/ArchiveIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
export { default as ArchiveIcon } from './ArchiveIcon';
|
||||||
export { default as Blocks } from './Blocks';
|
export { default as Blocks } from './Blocks';
|
||||||
export { default as Plugin } from './Plugin';
|
export { default as Plugin } from './Plugin';
|
||||||
export { default as GPTIcon } from './GPTIcon';
|
export { default as GPTIcon } from './GPTIcon';
|
||||||
|
|
|
@ -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 { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
UseInfiniteQueryOptions,
|
UseInfiniteQueryOptions,
|
||||||
|
@ -187,7 +187,7 @@ export const useAvailableToolsQuery = (): QueryObserverResult<TPlugin[]> => {
|
||||||
* Hook for listing all assistants, with optional parameters provided for pagination and sorting
|
* Hook for listing all assistants, with optional parameters provided for pagination and sorting
|
||||||
*/
|
*/
|
||||||
export const useListAssistantsQuery = <TData = AssistantListResponse>(
|
export const useListAssistantsQuery = <TData = AssistantListResponse>(
|
||||||
params?: AssistantListParams,
|
params: AssistantListParams = defaultOrderQuery,
|
||||||
config?: UseQueryOptions<AssistantListResponse, unknown, TData>,
|
config?: UseQueryOptions<AssistantListResponse, unknown, TData>,
|
||||||
): QueryObserverResult<TData> => {
|
): QueryObserverResult<TData> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { matchSorter } from 'match-sorter';
|
import { matchSorter } from 'match-sorter';
|
||||||
import type { OptionWithIcon } from '~/common';
|
import type { OptionWithIcon, MentionOption } from '~/common';
|
||||||
|
|
||||||
export default function useCombobox({
|
export default function useCombobox({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
options: OptionWithIcon[];
|
options: Array<OptionWithIcon | MentionOption>;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
118
client/src/hooks/Input/useMentions.ts
Normal file
118
client/src/hooks/Input/useMentions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
210
client/src/hooks/Input/useSelectMention.ts
Normal file
210
client/src/hooks/Input/useSelectMention.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { EModelEndpoint } from 'librechat-data-provider';
|
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 { TEndpointOption } from 'librechat-data-provider';
|
||||||
import type { KeyboardEvent } from 'react';
|
import type { KeyboardEvent } from 'react';
|
||||||
import { forceResize, insertTextAtCursor, getAssistantName } from '~/utils';
|
import { forceResize, insertTextAtCursor, getAssistantName } from '~/utils';
|
||||||
|
@ -23,20 +23,24 @@ export default function useTextarea({
|
||||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const assistantMap = useAssistantsMapContext();
|
const localize = useLocalize();
|
||||||
const enterToSend = useRecoilValue(store.enterToSend);
|
const getSender = useGetSender();
|
||||||
const {
|
|
||||||
conversation,
|
|
||||||
isSubmitting,
|
|
||||||
latestMessage,
|
|
||||||
setShowBingToneSetting,
|
|
||||||
filesLoading,
|
|
||||||
setFilesLoading,
|
|
||||||
} = useChatContext();
|
|
||||||
const isComposing = useRef(false);
|
const isComposing = useRef(false);
|
||||||
const { handleFiles } = useFileHandling();
|
const { handleFiles } = useFileHandling();
|
||||||
const getSender = useGetSender();
|
const assistantMap = useAssistantsMapContext();
|
||||||
const localize = useLocalize();
|
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 { conversationId, jailbreak, endpoint = '', assistant_id } = conversation || {};
|
||||||
const isNotAppendable =
|
const isNotAppendable =
|
||||||
|
@ -132,6 +136,30 @@ export default function useTextarea({
|
||||||
assistantMap,
|
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(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyEvent) => {
|
(e: KeyEvent) => {
|
||||||
if (e.key === 'Enter' && isSubmitting) {
|
if (e.key === 'Enter' && isSubmitting) {
|
||||||
|
@ -213,6 +241,7 @@ export default function useTextarea({
|
||||||
return {
|
return {
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
|
handleKeyUp,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleCompositionStart,
|
handleCompositionStart,
|
||||||
handleCompositionEnd,
|
handleCompositionEnd,
|
||||||
|
|
|
@ -125,6 +125,7 @@ export default {
|
||||||
com_user_message: 'You',
|
com_user_message: 'You',
|
||||||
com_ui_copy_to_clipboard: 'Copy to clipboard',
|
com_ui_copy_to_clipboard: 'Copy to clipboard',
|
||||||
com_ui_copied_to_clipboard: 'Copied 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_1: 'Use this setting to fork messages with the desired behavior.',
|
||||||
com_ui_fork_info_2:
|
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.',
|
'"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_branches: 'Include related branches',
|
||||||
com_ui_fork_visible: 'Visible messages only',
|
com_ui_fork_visible: 'Visible messages only',
|
||||||
com_ui_fork_from_message: 'Select a fork option',
|
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_regenerate: 'Regenerate',
|
||||||
com_ui_continue: 'Continue',
|
com_ui_continue: 'Continue',
|
||||||
com_ui_edit: 'Edit',
|
com_ui_edit: 'Edit',
|
||||||
|
|
|
@ -182,3 +182,92 @@
|
||||||
.rotate {
|
.rotate {
|
||||||
animation: tuning 2.1s 1;
|
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)
|
||||||
|
}
|
|
@ -107,6 +107,11 @@ const showPopoverFamily = atomFamily({
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showMentionPopoverFamily = atomFamily<boolean, string | number | null>({
|
||||||
|
key: 'showMentionPopoverByIndex',
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
const latestMessageFamily = atomFamily<TMessage | null, string | number | null>({
|
const latestMessageFamily = atomFamily<TMessage | null, string | number | null>({
|
||||||
key: 'latestMessageByIndex',
|
key: 'latestMessageByIndex',
|
||||||
default: null,
|
default: null,
|
||||||
|
@ -142,4 +147,5 @@ export default {
|
||||||
latestMessageFamily,
|
latestMessageFamily,
|
||||||
allConversationsSelector,
|
allConversationsSelector,
|
||||||
useCreateConversationAtom,
|
useCreateConversationAtom,
|
||||||
|
showMentionPopoverFamily,
|
||||||
};
|
};
|
||||||
|
|
|
@ -101,7 +101,7 @@ const hideSidePanel = atom<boolean>({
|
||||||
|
|
||||||
const modularChat = atom<boolean>({
|
const modularChat = atom<boolean>({
|
||||||
key: 'modularChat',
|
key: 'modularChat',
|
||||||
default: localStorage.getItem('modularChat') === 'true',
|
default: true,
|
||||||
effects: [
|
effects: [
|
||||||
({ setSelf, onSet }) => {
|
({ setSelf, onSet }) => {
|
||||||
const savedValue = localStorage.getItem('modularChat');
|
const savedValue = localStorage.getItem('modularChat');
|
||||||
|
|
|
@ -14,11 +14,13 @@ export const getPresetIcon = (preset: TPreset, Icon) => {
|
||||||
|
|
||||||
type TEndpoints = Array<string | EModelEndpoint>;
|
type TEndpoints = Array<string | EModelEndpoint>;
|
||||||
|
|
||||||
export const getPresetTitle = (preset: TPreset) => {
|
export const getPresetTitle = (preset: TPreset, mention?: boolean) => {
|
||||||
const {
|
const {
|
||||||
endpoint,
|
endpoint,
|
||||||
title: presetTitle,
|
title: presetTitle,
|
||||||
model,
|
model,
|
||||||
|
tools,
|
||||||
|
promptPrefix,
|
||||||
chatGptLabel,
|
chatGptLabel,
|
||||||
modelLabel,
|
modelLabel,
|
||||||
jailbreak,
|
jailbreak,
|
||||||
|
@ -51,6 +53,21 @@ export const getPresetTitle = (preset: TPreset) => {
|
||||||
title = presetTitle + ': ';
|
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();
|
return `${title}${modelInfo}${label ? ` (${label})` : ''}`.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -57,3 +57,19 @@ export const trimUndoneRange = (textAreaRef: React.RefObject<HTMLTextAreaElement
|
||||||
textAreaRef.current.value = newValue;
|
textAreaRef.current.value = newValue;
|
||||||
textAreaRef.current.setSelectionRange(selectionStart, selectionStart);
|
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();
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue