Skip to content

Commit

Permalink
📧 feat: Mention "@" Command Popover (danny-avila#2635)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
danny-avila authored May 7, 2024
1 parent 89b1e33 commit b6d6343
Show file tree
Hide file tree
Showing 35 changed files with 1,038 additions and 207 deletions.
3 changes: 2 additions & 1 deletion api/server/services/AuthService.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
1 change: 1 addition & 0 deletions client/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions client/src/components/Chat/Input/ActiveSetting.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
19 changes: 14 additions & 5 deletions client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,28 @@ 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({
textAreaRef,
submitButtonRef,
disabled: !!requiresKey,
});
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
useTextarea({
textAreaRef,
submitButtonRef,
disabled: !!requiresKey,
});

const {
ask,
Expand Down Expand Up @@ -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}
Expand All @@ -114,6 +122,7 @@ const ChatForm = ({ index = 0 }) => {
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
Expand Down
148 changes: 148 additions & 0 deletions client/src/components/Chat/Input/Mention.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 46 additions & 0 deletions client/src/components/Chat/Input/MentionItem.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
52 changes: 17 additions & 35 deletions client/src/components/Chat/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand All @@ -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" />
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Chat/Menus/Endpoints/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 7 additions & 7 deletions client/src/components/Chat/Messages/HoverButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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>
);
}
Loading

0 comments on commit b6d6343

Please sign in to comment.