mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01: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,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('');
|
||||
|
|
|
|||
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 { 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue