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

* feat: initial mockup

* wip: activesetting, may use or not use

* wip: mention with useCombobox usage

* feat: connect textarea to new mention popover

* refactor: consolidate icon logic for Landing/convos

* refactor: cleanup URL logic

* refactor(useTextarea): key up handler

* wip: render desired mention options

* refactor: improve mention detection

* feat: modular chat the default option

* WIP: first pass mention selection

* feat: scroll mention items with keypad

* chore(showMentionPopoverFamily): add typing to atomFamily

* feat: removeAtSymbol

* refactor(useListAssistantsQuery): use defaultOrderQuery as default param

* feat: assistants mentioning

* fix conversation switch errors

* filter mention selections based on startup settings and available endpoints

* fix: mentions model spec icon URL

* style: archive icon

* fix: convo renaming behavior on click

* fix(Convo): toggle hover state

* style: EditMenu refactor

* fix: archive chats table

* fix: errorsToString import

* chore: remove comments

* chore: remove comment

* feat: mention descriptions

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

View file

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

View file

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

View file

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

View file

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