mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-10 12:38:52 +01:00
🎨 feat: UI Refresh for Enhanced UX (#6346)
* ✨ feat: Add Expand Chat functionality and improve UI components * ✨ feat: Introduce Chat Badges feature with editing capabilities and UI enhancements * ✨ feat: re-implement file attachment functionality with new components and improved UI * ✨ feat: Enhance BadgeRow component with drag-and-drop functionality and add animations for better user experience * ✨ feat: Add useChatBadges hook and enhance Badge component with animations and toggle functionality * feat: Improve Add/Delete Badges + style and bug fixes * ✨ feat: Refactor EditBadges component and optimize useChatBadges hook for improved performance and readability * ✨ feat: Add type definition for LucideIcon in EditBadges component * refactor: Clean up BadgeRow component by removing outdated comment and improving code readability * refactor: Rename app-icon class to badge-icon for consistency and improve badge styling * feat: Add Center Chat Input toggle and update related components for improved UI/UX * refactor: Simplify ChatView and MessagesView components for improved readability and performance * refactor: Improve layout and positioning of scroll button in MessagesView component * refactor: Adjust scroll button position in MessagesView component for better visibility * refactor: Remove redundant background class from Badge component for cleaner styling * feat: disable chat badges * refactor: adjust positioning of scroll button and popover for improved layout * refactor: simplify class names in ChatForm and RemoveFile components for cleaner code * refactor: move Switcher to HeaderOptions from SidePanel * fix(Landing): duplicate description * feat: add SplitText component for animated text display and update Landing component to use it * feat(Chat): add ConversationStarters component and integrate it into ChatView; remove ConvoStarter component * feat(Chat): enhance Message component layout and styling for improved readability * feat(ControlCombobox, Select): enhance styling and add animation for improved UI experience * feat(Chat): update Header and HeaderNewChat components for improved layout and styling * feat(Chat): add ModelDropdown (now includes both endpoint and model) and refactor Menu components for improved UI * feat(ModelDropdown): add Agent Select; removed old AgentSwitcher components * feat(ModelDropdown): add settings button for user key configuration * fix(ModelDropdown): the model dropdown wasn't opening automatically when opening the endpoint one * refactor(Chat): remove unused EndpointsMenu and related components to streamline codebase * feat: enhance greeting message and improve accessibility fro ModelDropdown * refactor(Endpoints): add new hooks and components for endpoint management * feat(Endpoint): add support for modelSpecs * feat(Endpoints): add mobile support * fix: type issues * fix(modelSpec): type issue * fix(EndpointMenuDropdown): double overflow scroller in mobile model list * fix: search model on mobile * refactor: Endpoint/Model/modelSpec dropdown * refactor: reorganize imports in Endpoint components * refactor: remove unused translation keys from English locale * BREAKING: moving to ariakit with new CustomMenu * refactor: remove unnecessary comments * refactor: remove EndpointItem, ModelDropdownButton, SpecIcon, and SpecItem components * 🔧 fix: AI Icon bump when regenerating message * wip: chat UI refactoring, fix issues * chore: add recent update to useAutoSave * feat: add access control for agent permissions in useMentions hook * refactor: streamline ModelSelector by removing unused endpoints logic * refactor: enhance ModelSelector and context by integrating endpointsConfig and improving type usage * feat: update ModelSelectorContext to utilize conversation data for initial state * feat: add selector effects for synced endpoint handling * feat: add guard clause for conversation endpoint in useSelectorEffects hook * fix: safely call onSelectMention and add autofocus to mention input * chore: typing * refactor: ModelSelector to streamline key dialog handling and improve endpoint rendering * refactor: extract SettingsButton component for cleaner endpoint item rendering * wip: first pass, expand set api key * wip: first pass, expanding set key * refactor: update EndpointItem styles for improved layout and hover effects * refactor: adjust padding in EndpointItem for improved layout consistency * refactor: update preset structure in useSelectMention to include spec as null * refactor: rename setKeyDialogOpen to onOpenChange for clarity and consistency, bring focus back to button that opened dialog * feat: add SpecIcon component for dynamic model spec icons in menu, adjust icon styling * refactor: update getSelectedIcon to accept additional parameters and improve icon rendering logic * fix: adjust padding in MessageRender for improved layout * refactor: remove inline style for menu width in CustomMenu component * refactor: enhance layout and styling in ModelSpecItem component for better responsiveness * refactor: update getDefaultModelSpec to accept startupConfig and improve model spec retrieval logic * refactor: improve key management and default values in ModelSelector and related components * refactor: adjust menu width and improve responsiveness in CustomMenu and EndpointItem components * refactor: enhance focus styles and responsiveness in EndpointItem component * refactor: improve layout and spacing in Header and ModelSelector components for better responsiveness * refactor: adjust button styles for consistency and improved layout in AddMultiConvo and PresetsMenu components * fix: initial fix of assistant names * fix: assistants handling * chore: update version of librechat-data-provider to 0.7.75 and add 'spec' to excludedKeys * fix: improve endpoint filtering logic based on interface configuration and access rights * fix: remove unused HeaderOptions import and set spec to null in presets and mentions * fix: ensure currentExample is always an object when updating examples * fix: update interfaceConfig checks to ensure modelSelect is considered for rendering components * fix: update model selection logic to consider interface configuration when prioritizing model specs * fix: add missing localizations * fix: remove unused agent and assistant selection translations * fix: implement debounced state updates for selected values in useSelectorEffects * style: minor style changes related to the ModelSelector * fix: adjust maximum height for popover and set fixed height for model item * fix: update placeholders for model and endpoint search inputs * fix: refactor MessageRender and ContentRender components to better match each other * fix: remove convo fallback for iconURL in MessageRender and ContentRender components * fix: update handling of spec, iconURL, and modelLabel in conversation presets, to allow better interchangeability * fix: replace chatGptLabel with modelLabel in OpenAI settings configuration (fully deprecate chatGptLabel) * fix: remove console log for assistantNames in useEndpoints hook * refactor: add cleanInput and cleanOutput options to default conversation handling * chore: update bun.lockb * fix: set default value for showIconInHeader in getSelectedIcon function * refactor: enhance error handling in message processing when latest message has existing content blocks * chore: allow import/no-cycle for messages * fix: adjust flex properties in BookmarkMenu for better layout * feat: support both 'prompt' and 'q' as query parameters in useQueryParams hook * feat: re-enable Badges components * refactor: disable edit badge component * chore: rename assistantMap to assistantsMap for consistency * chore: rename assistantMap to assistantsMap for consistency in Mention component * feat: set staleTime for various queries to improve data freshness * feat: add spec field to tQueryParamsSchema for model specification * feat: enhance useQueryParams to handle model specs --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
c4fea9cd79
commit
7f29f2f676
127 changed files with 4507 additions and 2163 deletions
246
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
246
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import * as React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export interface CustomMenuProps extends Ariakit.MenuButtonProps<'div'> {
|
||||
label?: React.ReactNode;
|
||||
values?: Record<string, any>;
|
||||
onValuesChange?: (values: Record<string, any>) => void;
|
||||
searchValue?: string;
|
||||
onSearch?: (value: string) => void;
|
||||
combobox?: Ariakit.ComboboxProps['render'];
|
||||
trigger?: Ariakit.MenuButtonProps['render'];
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(function CustomMenu(
|
||||
{
|
||||
label,
|
||||
children,
|
||||
values,
|
||||
onValuesChange,
|
||||
searchValue,
|
||||
onSearch,
|
||||
combobox,
|
||||
trigger,
|
||||
defaultOpen,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const parent = Ariakit.useMenuContext();
|
||||
const searchable = searchValue != null || !!onSearch || !!combobox;
|
||||
|
||||
const menuStore = Ariakit.useMenuStore({
|
||||
showTimeout: 100,
|
||||
placement: parent ? 'right' : 'left',
|
||||
defaultOpen: defaultOpen,
|
||||
});
|
||||
|
||||
const element = (
|
||||
<Ariakit.MenuProvider store={menuStore} values={values} setValues={onValuesChange}>
|
||||
<Ariakit.MenuButton
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
!parent &&
|
||||
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white',
|
||||
menuStore.useState('open')
|
||||
? 'bg-surface-tertiary hover:bg-surface-tertiary'
|
||||
: 'bg-surface-secondary hover:bg-surface-tertiary',
|
||||
props.className,
|
||||
)}
|
||||
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
|
||||
>
|
||||
<span className="flex-1">{label}</span>
|
||||
<Ariakit.MenuButtonArrow className="stroke-1 text-base opacity-75" />
|
||||
</Ariakit.MenuButton>
|
||||
<Ariakit.Menu
|
||||
open={menuStore.useState('open')}
|
||||
portal
|
||||
overlap
|
||||
unmountOnHide
|
||||
gutter={parent ? -4 : 4}
|
||||
className={cn(
|
||||
`${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`,
|
||||
'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light',
|
||||
'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg',
|
||||
'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]',
|
||||
searchable && 'p-0',
|
||||
)}
|
||||
>
|
||||
<SearchableContext.Provider value={searchable}>
|
||||
{searchable ? (
|
||||
<>
|
||||
<div className="sticky top-0 z-10 bg-inherit p-1">
|
||||
<Ariakit.Combobox
|
||||
autoSelect
|
||||
render={combobox}
|
||||
className={cn(
|
||||
'h-10 w-full rounded border-none bg-transparent px-2 text-base',
|
||||
'sm:h-8 sm:text-sm',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Ariakit.ComboboxList className="p-0.5 pt-0">{children}</Ariakit.ComboboxList>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SearchableContext.Provider>
|
||||
</Ariakit.Menu>
|
||||
</Ariakit.MenuProvider>
|
||||
);
|
||||
|
||||
if (searchable) {
|
||||
return (
|
||||
<Ariakit.ComboboxProvider
|
||||
resetValueOnHide
|
||||
includesBaseElement={false}
|
||||
value={searchValue}
|
||||
setValue={onSearch}
|
||||
>
|
||||
{element}
|
||||
</Ariakit.ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return element;
|
||||
});
|
||||
|
||||
export const CustomMenuSeparator = React.forwardRef<HTMLHRElement, Ariakit.MenuSeparatorProps>(
|
||||
function CustomMenuSeparator(props, ref) {
|
||||
return (
|
||||
<Ariakit.MenuSeparator
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'my-0.5 h-0 w-full border-t border-slate-200 dark:border-slate-700',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface CustomMenuGroupProps extends Ariakit.MenuGroupProps {
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomMenuGroup = React.forwardRef<HTMLDivElement, CustomMenuGroupProps>(
|
||||
function CustomMenuGroup({ label, ...props }, ref) {
|
||||
return (
|
||||
<Ariakit.MenuGroup ref={ref} {...props} className={cn('', props.className)}>
|
||||
{label && (
|
||||
<Ariakit.MenuGroupLabel className="cursor-default p-2 text-sm font-medium opacity-60 sm:py-1 sm:text-xs">
|
||||
{label}
|
||||
</Ariakit.MenuGroupLabel>
|
||||
)}
|
||||
{props.children}
|
||||
</Ariakit.MenuGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const SearchableContext = React.createContext(false);
|
||||
|
||||
export interface CustomMenuItemProps extends Omit<Ariakit.ComboboxItemProps, 'store'> {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemProps>(
|
||||
function CustomMenuItem({ name, value, ...props }, ref) {
|
||||
const menu = Ariakit.useMenuContext();
|
||||
const searchable = React.useContext(SearchableContext);
|
||||
const defaultProps: CustomMenuItemProps = {
|
||||
ref,
|
||||
focusOnHover: true,
|
||||
blurOnHoverEnd: false,
|
||||
...props,
|
||||
className: cn(
|
||||
'flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full',
|
||||
props.className,
|
||||
),
|
||||
};
|
||||
|
||||
const checkable = Ariakit.useStoreState(menu, (state) => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
return state?.values[name] != null;
|
||||
});
|
||||
|
||||
const checked = Ariakit.useStoreState(menu, (state) => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return state?.values[name] === value;
|
||||
});
|
||||
|
||||
// If the item is checkable, we render a checkmark icon next to the label.
|
||||
if (checkable) {
|
||||
defaultProps.children = (
|
||||
<React.Fragment>
|
||||
<span className="flex-1">{defaultProps.children}</span>
|
||||
<Ariakit.MenuItemCheck checked={checked} />
|
||||
{searchable && (
|
||||
// When an item is displayed in a search menu as a role=option
|
||||
// element instead of a role=menuitemradio, we can't depend on the
|
||||
// aria-checked attribute. Although NVDA and JAWS announce it
|
||||
// accurately, VoiceOver doesn't. TalkBack does announce the checked
|
||||
// state, but misleadingly implies that a double tap will change the
|
||||
// state, which isn't the case. Therefore, we use a visually hidden
|
||||
// element to indicate whether the item is checked or not, ensuring
|
||||
// cross-browser/AT compatibility.
|
||||
<Ariakit.VisuallyHidden>{checked ? 'checked' : 'not checked'}</Ariakit.VisuallyHidden>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
// If the item is not rendered in a search menu (listbox), we can render it
|
||||
// as a MenuItem/MenuItemRadio.
|
||||
if (!searchable) {
|
||||
if (name != null && value != null) {
|
||||
const radioProps = { ...defaultProps, name, value, hideOnClick: true };
|
||||
return <Ariakit.MenuItemRadio {...radioProps} />;
|
||||
}
|
||||
return <Ariakit.MenuItem {...defaultProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Ariakit.ComboboxItem
|
||||
{...defaultProps}
|
||||
setValueOnClick={false}
|
||||
value={checkable ? value : undefined}
|
||||
selectValueOnClick={() => {
|
||||
if (name == null || value == null) {
|
||||
return false;
|
||||
}
|
||||
// By default, clicking on a ComboboxItem will update the
|
||||
// selectedValue state of the combobox. However, since we're sharing
|
||||
// state between combobox and menu, we also need to update the menu's
|
||||
// values state.
|
||||
menu?.setValue(name, value);
|
||||
return true;
|
||||
}}
|
||||
hideOnClick={(event) => {
|
||||
// Make sure that clicking on a combobox item that opens a nested
|
||||
// menu/dialog does not close the menu.
|
||||
const expandable = event.currentTarget.hasAttribute('aria-expanded');
|
||||
if (expandable) {
|
||||
return false;
|
||||
}
|
||||
// By default, clicking on a ComboboxItem only closes its own popover.
|
||||
// However, since we're in a menu context, we also close all parent
|
||||
// menus.
|
||||
menu?.hideAll();
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
34
client/src/components/Chat/Menus/Endpoints/DialogManager.tsx
Normal file
34
client/src/components/Chat/Menus/Endpoints/DialogManager.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { getEndpointField } from '~/utils';
|
||||
|
||||
interface DialogManagerProps {
|
||||
keyDialogOpen: boolean;
|
||||
keyDialogEndpoint?: EModelEndpoint;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
endpointsConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
const DialogManager = ({
|
||||
keyDialogOpen,
|
||||
keyDialogEndpoint,
|
||||
onOpenChange,
|
||||
endpointsConfig,
|
||||
}: DialogManagerProps) => {
|
||||
return (
|
||||
<>
|
||||
{keyDialogEndpoint && (
|
||||
<SetKeyDialog
|
||||
open={keyDialogOpen}
|
||||
endpoint={keyDialogEndpoint}
|
||||
endpointType={getEndpointField(endpointsConfig, keyDialogEndpoint, 'type')}
|
||||
onOpenChange={onOpenChange}
|
||||
userProvideURL={getEndpointField(endpointsConfig, keyDialogEndpoint, 'userProvideURL')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogManager;
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { IconMapProps, AgentIconMapProps, IconsRecord } from '~/common';
|
||||
import { Feather } from 'lucide-react';
|
||||
import {
|
||||
MinimalPlugin,
|
||||
GPTIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
GoogleMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
AssistantIcon,
|
||||
LightningIcon,
|
||||
BedrockIcon,
|
||||
Sparkles,
|
||||
} from '~/components/svg';
|
||||
import UnknownIcon from './UnknownIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AssistantAvatar = ({
|
||||
className = '',
|
||||
assistantName = '',
|
||||
avatar = '',
|
||||
context,
|
||||
size,
|
||||
}: IconMapProps) => {
|
||||
if (assistantName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
|
||||
alt={assistantName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
} else if (assistantName) {
|
||||
return <AssistantIcon className={cn('text-token-secondary', className)} size={size} />;
|
||||
}
|
||||
|
||||
return <Sparkles className={cn(context === 'landing' ? 'icon-2xl' : '', className)} />;
|
||||
};
|
||||
|
||||
const AgentAvatar = ({ className = '', avatar = '', agentName, size }: AgentIconMapProps) => {
|
||||
if (agentName != null && agentName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
|
||||
alt={agentName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Feather className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
|
||||
};
|
||||
|
||||
const Bedrock = ({ className = '' }: IconMapProps) => {
|
||||
return <BedrockIcon className={cn(className, 'h-full w-full')} />;
|
||||
};
|
||||
|
||||
export const icons: IconsRecord = {
|
||||
[EModelEndpoint.azureOpenAI]: AzureMinimalIcon,
|
||||
[EModelEndpoint.openAI]: GPTIcon,
|
||||
[EModelEndpoint.gptPlugins]: MinimalPlugin,
|
||||
[EModelEndpoint.anthropic]: AnthropicIcon,
|
||||
[EModelEndpoint.chatGPTBrowser]: LightningIcon,
|
||||
[EModelEndpoint.google]: GoogleMinimalIcon,
|
||||
[EModelEndpoint.custom]: CustomMinimalIcon,
|
||||
[EModelEndpoint.assistants]: AssistantAvatar,
|
||||
[EModelEndpoint.azureAssistants]: AssistantAvatar,
|
||||
[EModelEndpoint.agents]: AgentAvatar,
|
||||
[EModelEndpoint.bedrock]: Bedrock,
|
||||
unknown: UnknownIcon,
|
||||
};
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import { cn, getConvoSwitchLogic, getEndpointField, getIconKey } from '~/utils';
|
||||
import { useLocalize, useUserKey, useDefaultConvo } from '~/hooks';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { icons } from './Icons';
|
||||
import store from '~/store';
|
||||
|
||||
type MenuItemProps = {
|
||||
title: string;
|
||||
value: EModelEndpoint;
|
||||
selected: boolean;
|
||||
description?: string;
|
||||
userProvidesKey: boolean;
|
||||
// iconPath: string;
|
||||
// hoverContent?: string;
|
||||
};
|
||||
|
||||
const MenuItem: FC<MenuItemProps> = ({
|
||||
title,
|
||||
value: endpoint,
|
||||
description,
|
||||
selected,
|
||||
userProvidesKey,
|
||||
...rest
|
||||
}) => {
|
||||
const modularChat = useRecoilValue(store.modularChat);
|
||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
|
||||
const { getExpiry } = useUserKey(endpoint);
|
||||
const localize = useLocalize();
|
||||
const expiryTime = getExpiry() ?? '';
|
||||
|
||||
const onSelectEndpoint = (newEndpoint?: EModelEndpoint) => {
|
||||
if (!newEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expiryTime) {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
const {
|
||||
template,
|
||||
shouldSwitch,
|
||||
isNewModular,
|
||||
newEndpointType,
|
||||
isCurrentModular,
|
||||
isExistingConversation,
|
||||
} = getConvoSwitchLogic({
|
||||
newEndpoint,
|
||||
modularChat,
|
||||
conversation,
|
||||
endpointsConfig,
|
||||
});
|
||||
|
||||
const isModular = isCurrentModular && isNewModular && shouldSwitch;
|
||||
if (isExistingConversation && isModular) {
|
||||
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,
|
||||
keepAddedConvos: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
newConversation({
|
||||
template: { ...(template as Partial<TConversation>) },
|
||||
keepAddedConvos: isModular,
|
||||
});
|
||||
};
|
||||
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType });
|
||||
const Icon = icons[iconKey];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'group m-1.5 flex max-h-[40px] cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-surface-hover',
|
||||
'radix-disabled:pointer-events-none radix-disabled:opacity-50',
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
onClick={() => onSelectEndpoint(endpoint)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSelectEndpoint(endpoint);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon != null && (
|
||||
<Icon
|
||||
size={18}
|
||||
endpoint={endpoint}
|
||||
context={'menu-item'}
|
||||
className="icon-md shrink-0 dark:text-white"
|
||||
iconURL={getEndpointField(endpointsConfig, endpoint, 'iconURL')}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{title}
|
||||
<div className="text-token-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{userProvidesKey ? (
|
||||
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
aria-label={`${localize('com_endpoint_config_key')} for ${title}`}
|
||||
className={cn(
|
||||
'invisible flex gap-x-1 group-focus-within:visible group-hover:visible',
|
||||
selected ? 'visible' : '',
|
||||
expiryTime ? 'text-token-text-primary w-full rounded-lg p-2' : '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'invisible group-focus-within:visible group-hover:visible',
|
||||
expiryTime ? 'text-xs' : '',
|
||||
)}
|
||||
>
|
||||
{localize('com_endpoint_config_key')}
|
||||
</div>
|
||||
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{selected && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{(!userProvidesKey || expiryTime) && (
|
||||
<div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
||||
{!userProvidesKey && <div className="">{localize('com_ui_new_chat')}</div>}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{userProvidesKey && (
|
||||
<SetKeyDialog
|
||||
open={isDialogOpen}
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
onOpenChange={setDialogOpen}
|
||||
userProvideURL={getEndpointField(endpointsConfig, endpoint, 'userProvideURL')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
import { Close } from '@radix-ui/react-popover';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
alternateName,
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
} from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import MenuSeparator from '../UI/MenuSeparator';
|
||||
import { getEndpointField } from '~/utils';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
const EndpointItems: FC<{
|
||||
endpoints: Array<EModelEndpoint | undefined>;
|
||||
selected: EModelEndpoint | '';
|
||||
}> = ({ endpoints = [], selected }) => {
|
||||
const hasAccessToAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
return (
|
||||
<>
|
||||
{endpoints.map((endpoint, i) => {
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
} else if (!endpointsConfig?.[endpoint]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.agents && !hasAccessToAgents) {
|
||||
return null;
|
||||
}
|
||||
const userProvidesKey: boolean | null | undefined =
|
||||
getEndpointField(endpointsConfig, endpoint, 'userProvide') ?? false;
|
||||
return (
|
||||
<Close asChild key={`endpoint-${endpoint}`}>
|
||||
<div key={`endpoint-${endpoint}`}>
|
||||
<MenuItem
|
||||
key={`endpoint-item-${endpoint}`}
|
||||
title={alternateName[endpoint] || endpoint}
|
||||
value={endpoint}
|
||||
selected={selected === endpoint}
|
||||
data-testid={`endpoint-item-${endpoint}`}
|
||||
userProvidesKey={!!userProvidesKey}
|
||||
// description="With DALL·E, browsing and analysis"
|
||||
/>
|
||||
{i !== endpoints.length - 1 && <MenuSeparator />}
|
||||
</div>
|
||||
</Close>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointItems;
|
||||
95
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
95
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import type { ModelSelectorProps } from '~/common';
|
||||
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
|
||||
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
|
||||
import { CustomMenu as Menu } from './CustomMenu';
|
||||
import DialogManager from './DialogManager';
|
||||
import { getSelectedIcon } from './utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function ModelSelectorContent() {
|
||||
const localize = useLocalize();
|
||||
|
||||
const {
|
||||
// LibreChat
|
||||
modelSpecs,
|
||||
mappedEndpoints,
|
||||
endpointsConfig,
|
||||
// State
|
||||
searchValue,
|
||||
searchResults,
|
||||
selectedValues,
|
||||
|
||||
// Functions
|
||||
setSearchValue,
|
||||
getDisplayValue,
|
||||
setSelectedValues,
|
||||
// Dialog
|
||||
keyDialogOpen,
|
||||
onOpenChange,
|
||||
keyDialogEndpoint,
|
||||
} = useModelSelectorContext();
|
||||
|
||||
const selectedIcon = getSelectedIcon({
|
||||
mappedEndpoints: mappedEndpoints ?? [],
|
||||
selectedValues,
|
||||
modelSpecs,
|
||||
endpointsConfig,
|
||||
});
|
||||
const selectedDisplayValue = getDisplayValue();
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
aria-label={localize('com_endpoint_select_model')}
|
||||
>
|
||||
{selectedIcon && React.isValidElement(selectedIcon) && (
|
||||
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
|
||||
{selectedIcon}
|
||||
</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{selectedDisplayValue}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full max-w-md flex-col items-center gap-2">
|
||||
<Menu
|
||||
values={selectedValues}
|
||||
onValuesChange={(values: Record<string, any>) => {
|
||||
setSelectedValues({
|
||||
endpoint: values.endpoint || '',
|
||||
model: values.model || '',
|
||||
modelSpec: values.modelSpec || '',
|
||||
});
|
||||
}}
|
||||
onSearch={(value) => setSearchValue(value)}
|
||||
combobox={<input placeholder={localize('com_endpoint_search_models')} />}
|
||||
trigger={trigger}
|
||||
>
|
||||
{searchResults ? (
|
||||
renderSearchResults(searchResults, localize, searchValue)
|
||||
) : (
|
||||
<>
|
||||
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')}
|
||||
{renderEndpoints(mappedEndpoints ?? [])}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<DialogManager
|
||||
keyDialogOpen={keyDialogOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
endpointsConfig={endpointsConfig || {}}
|
||||
keyDialogEndpoint={keyDialogEndpoint || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ModelSelector({ interfaceConfig, modelSpecs }: ModelSelectorProps) {
|
||||
return (
|
||||
<ModelSelectorProvider modelSpecs={modelSpecs} interfaceConfig={interfaceConfig}>
|
||||
<ModelSelectorContent />
|
||||
</ModelSelectorProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import React, { startTransition, createContext, useContext, useState, useMemo } from 'react';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { Endpoint, SelectedValues } from '~/common';
|
||||
import { useAgentsMapContext, useAssistantsMapContext, useChatContext } from '~/Providers';
|
||||
import { useEndpoints, useSelectorEffects, useKeyDialog, useLocalize } from '~/hooks';
|
||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { filterItems } from './utils';
|
||||
|
||||
type ModelSelectorContextType = {
|
||||
// State
|
||||
searchValue: string;
|
||||
selectedValues: SelectedValues;
|
||||
endpointSearchValues: Record<string, string>;
|
||||
searchResults: (t.TModelSpec | Endpoint)[] | null;
|
||||
// LibreChat
|
||||
modelSpecs: t.TModelSpec[];
|
||||
mappedEndpoints: Endpoint[];
|
||||
agentsMap: t.TAgentsMap | undefined;
|
||||
assistantsMap: t.TAssistantsMap | undefined;
|
||||
endpointsConfig: t.TEndpointsConfig;
|
||||
|
||||
// Functions
|
||||
getDisplayValue: () => string;
|
||||
endpointRequiresUserKey: (endpoint: string) => boolean;
|
||||
setSelectedValues: React.Dispatch<React.SetStateAction<SelectedValues>>;
|
||||
setSearchValue: (value: string) => void;
|
||||
setEndpointSearchValue: (endpoint: string, value: string) => void;
|
||||
handleSelectSpec: (spec: t.TModelSpec) => void;
|
||||
handleSelectEndpoint: (endpoint: Endpoint) => void;
|
||||
handleSelectModel: (endpoint: Endpoint, model: string) => void;
|
||||
} & ReturnType<typeof useKeyDialog>;
|
||||
|
||||
const ModelSelectorContext = createContext<ModelSelectorContextType | undefined>(undefined);
|
||||
|
||||
export function useModelSelectorContext() {
|
||||
const context = useContext(ModelSelectorContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useModelSelectorContext must be used within a ModelSelectorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ModelSelectorProviderProps {
|
||||
children: React.ReactNode;
|
||||
modelSpecs: t.TModelSpec[];
|
||||
interfaceConfig: t.TInterfaceConfig;
|
||||
}
|
||||
|
||||
export function ModelSelectorProvider({
|
||||
children,
|
||||
modelSpecs,
|
||||
interfaceConfig,
|
||||
}: ModelSelectorProviderProps) {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const { mappedEndpoints, endpointRequiresUserKey } = useEndpoints({
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
interfaceConfig,
|
||||
});
|
||||
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
||||
// presets,
|
||||
modelSpecs,
|
||||
assistantsMap,
|
||||
endpointsConfig,
|
||||
newConversation,
|
||||
returnHandlers: true,
|
||||
});
|
||||
|
||||
// State
|
||||
const [selectedValues, setSelectedValues] = useState<SelectedValues>({
|
||||
endpoint: conversation?.endpoint || '',
|
||||
model: conversation?.model || '',
|
||||
modelSpec: conversation?.spec || '',
|
||||
});
|
||||
useSelectorEffects({
|
||||
agentsMap,
|
||||
conversation,
|
||||
assistantsMap,
|
||||
setSelectedValues,
|
||||
});
|
||||
|
||||
const [searchValue, setSearchValueState] = useState('');
|
||||
const [endpointSearchValues, setEndpointSearchValues] = useState<Record<string, string>>({});
|
||||
|
||||
const keyProps = useKeyDialog();
|
||||
|
||||
// Memoized search results
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchValue) {
|
||||
return null;
|
||||
}
|
||||
const allItems = [...modelSpecs, ...mappedEndpoints];
|
||||
return filterItems(allItems, searchValue, agentsMap, assistantsMap || {});
|
||||
}, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap]);
|
||||
|
||||
// Functions
|
||||
const setSearchValue = (value: string) => {
|
||||
startTransition(() => setSearchValueState(value));
|
||||
};
|
||||
|
||||
const setEndpointSearchValue = (endpoint: string, value: string) => {
|
||||
setEndpointSearchValues((prev) => ({
|
||||
...prev,
|
||||
[endpoint]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectSpec = (spec: t.TModelSpec) => {
|
||||
onSelectSpec?.(spec);
|
||||
setSelectedValues({
|
||||
endpoint: spec.preset.endpoint,
|
||||
model: spec.preset.model ?? null,
|
||||
modelSpec: spec.name,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectEndpoint = (endpoint: Endpoint) => {
|
||||
if (!endpoint.hasModels) {
|
||||
if (endpoint.value) {
|
||||
onSelectEndpoint?.(endpoint.value);
|
||||
}
|
||||
setSelectedValues({
|
||||
endpoint: endpoint.value,
|
||||
model: '',
|
||||
modelSpec: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectModel = (endpoint: Endpoint, model: string) => {
|
||||
if (isAgentsEndpoint(endpoint.value)) {
|
||||
onSelectEndpoint?.(endpoint.value, {
|
||||
agent_id: model,
|
||||
});
|
||||
} else if (isAssistantsEndpoint(endpoint.value)) {
|
||||
onSelectEndpoint?.(endpoint.value, {
|
||||
assistant_id: model,
|
||||
model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '',
|
||||
});
|
||||
} else if (endpoint.value) {
|
||||
onSelectEndpoint?.(endpoint.value, { model });
|
||||
}
|
||||
setSelectedValues({
|
||||
endpoint: endpoint.value,
|
||||
model: model,
|
||||
modelSpec: '',
|
||||
});
|
||||
};
|
||||
|
||||
const getDisplayValue = () => {
|
||||
if (selectedValues.modelSpec) {
|
||||
const spec = modelSpecs.find((s) => s.name === selectedValues.modelSpec);
|
||||
return spec?.label || localize('com_endpoint_select_model');
|
||||
}
|
||||
|
||||
if (selectedValues.model && selectedValues.endpoint) {
|
||||
const endpoint = mappedEndpoints.find((e) => e.value === selectedValues.endpoint);
|
||||
if (!endpoint) {
|
||||
return localize('com_endpoint_select_model');
|
||||
}
|
||||
|
||||
if (
|
||||
endpoint.value === EModelEndpoint.agents &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[selectedValues.model]
|
||||
) {
|
||||
return endpoint.agentNames[selectedValues.model];
|
||||
}
|
||||
|
||||
if (
|
||||
(endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
endpoint.assistantNames &&
|
||||
endpoint.assistantNames[selectedValues.model]
|
||||
) {
|
||||
return endpoint.assistantNames[selectedValues.model];
|
||||
}
|
||||
|
||||
return selectedValues.model;
|
||||
}
|
||||
|
||||
if (selectedValues.endpoint) {
|
||||
const endpoint = mappedEndpoints.find((e) => e.value === selectedValues.endpoint);
|
||||
return endpoint?.label || localize('com_endpoint_select_model');
|
||||
}
|
||||
|
||||
return localize('com_endpoint_select_model');
|
||||
};
|
||||
|
||||
const value = {
|
||||
// State
|
||||
searchValue,
|
||||
searchResults,
|
||||
selectedValues,
|
||||
endpointSearchValues,
|
||||
// LibreChat
|
||||
agentsMap,
|
||||
modelSpecs,
|
||||
assistantsMap,
|
||||
mappedEndpoints,
|
||||
endpointsConfig,
|
||||
|
||||
// Functions
|
||||
setSearchValue,
|
||||
getDisplayValue,
|
||||
handleSelectSpec,
|
||||
handleSelectModel,
|
||||
setSelectedValues,
|
||||
handleSelectEndpoint,
|
||||
setEndpointSearchValue,
|
||||
endpointRequiresUserKey,
|
||||
// Dialog
|
||||
...keyProps,
|
||||
};
|
||||
|
||||
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import { EModelEndpoint, KnownEndpoints } from 'librechat-data-provider';
|
||||
import { CustomMinimalIcon } from '~/components/svg';
|
||||
import { IconContext } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const knownEndpointAssets = {
|
||||
[KnownEndpoints.anyscale]: '/assets/anyscale.png',
|
||||
[KnownEndpoints.apipie]: '/assets/apipie.png',
|
||||
[KnownEndpoints.cohere]: '/assets/cohere.png',
|
||||
[KnownEndpoints.deepseek]: '/assets/deepseek.svg',
|
||||
[KnownEndpoints.fireworks]: '/assets/fireworks.png',
|
||||
[KnownEndpoints.groq]: '/assets/groq.png',
|
||||
[KnownEndpoints.huggingface]: '/assets/huggingface.svg',
|
||||
[KnownEndpoints.mistral]: '/assets/mistral.png',
|
||||
[KnownEndpoints.mlx]: '/assets/mlx.png',
|
||||
[KnownEndpoints.ollama]: '/assets/ollama.png',
|
||||
[KnownEndpoints.openrouter]: '/assets/openrouter.png',
|
||||
[KnownEndpoints.perplexity]: '/assets/perplexity.png',
|
||||
[KnownEndpoints.shuttleai]: '/assets/shuttleai.png',
|
||||
[KnownEndpoints['together.ai']]: '/assets/together.png',
|
||||
[KnownEndpoints.unify]: '/assets/unify.webp',
|
||||
[KnownEndpoints.xai]: '/assets/xai.svg',
|
||||
};
|
||||
|
||||
const knownEndpointClasses = {
|
||||
[KnownEndpoints.cohere]: {
|
||||
[IconContext.landing]: 'p-2',
|
||||
},
|
||||
[KnownEndpoints.xai]: {
|
||||
[IconContext.landing]: 'p-2',
|
||||
[IconContext.menuItem]: 'bg-white',
|
||||
[IconContext.message]: 'bg-white',
|
||||
[IconContext.nav]: 'bg-white',
|
||||
},
|
||||
};
|
||||
|
||||
const getKnownClass = ({
|
||||
currentEndpoint,
|
||||
context = '',
|
||||
className,
|
||||
}: {
|
||||
currentEndpoint: string;
|
||||
context?: string;
|
||||
className: string;
|
||||
}) => {
|
||||
if (currentEndpoint === KnownEndpoints.openrouter) {
|
||||
return className;
|
||||
}
|
||||
|
||||
const match = knownEndpointClasses[currentEndpoint]?.[context] ?? '';
|
||||
const defaultClass = context === IconContext.landing ? '' : className;
|
||||
|
||||
return cn(match, defaultClass);
|
||||
};
|
||||
|
||||
function UnknownIcon({
|
||||
className = '',
|
||||
endpoint: _endpoint,
|
||||
iconURL = '',
|
||||
context,
|
||||
}: {
|
||||
iconURL?: string;
|
||||
className?: string;
|
||||
endpoint?: EModelEndpoint | string | null;
|
||||
context?: 'landing' | 'menu-item' | 'nav' | 'message';
|
||||
}) {
|
||||
const endpoint = _endpoint ?? '';
|
||||
if (!endpoint) {
|
||||
return <CustomMinimalIcon className={className} />;
|
||||
}
|
||||
|
||||
const currentEndpoint = endpoint.toLowerCase();
|
||||
|
||||
if (iconURL) {
|
||||
return <img className={className} src={iconURL} alt={`${endpoint} Icon`} />;
|
||||
}
|
||||
|
||||
const assetPath: string = knownEndpointAssets[currentEndpoint] ?? '';
|
||||
|
||||
if (!assetPath) {
|
||||
return <CustomMinimalIcon className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={getKnownClass({
|
||||
currentEndpoint,
|
||||
context: context,
|
||||
className,
|
||||
})}
|
||||
src={assetPath}
|
||||
alt={`${currentEndpoint} Icon`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(UnknownIcon);
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SettingsIcon } from 'lucide-react';
|
||||
import { Spinner } from '~/components';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { Endpoint } from '~/common';
|
||||
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||
import { renderEndpointModels } from './EndpointModelItem';
|
||||
import { filterModels } from '../utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface EndpointItemProps {
|
||||
endpoint: Endpoint;
|
||||
}
|
||||
|
||||
const SettingsButton = ({
|
||||
endpoint,
|
||||
className,
|
||||
handleOpenKeyDialog,
|
||||
}: {
|
||||
endpoint: Endpoint;
|
||||
className?: string;
|
||||
handleOpenKeyDialog: (endpoint: EModelEndpoint, e: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const text = localize('com_endpoint_config_key');
|
||||
return (
|
||||
<button
|
||||
id={`endpoint-${endpoint.value}-settings`}
|
||||
onClick={(e) => {
|
||||
if (!endpoint.value) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center overflow-visible text-text-primary transition-all duration-300 ease-in-out',
|
||||
'group/button rounded-md px-1 hover:bg-surface-secondary focus:bg-surface-secondary',
|
||||
className,
|
||||
)}
|
||||
aria-label={`${text} ${endpoint.label}`}
|
||||
>
|
||||
<div className="flex w-[28px] items-center gap-1 whitespace-nowrap transition-all duration-300 ease-in-out group-hover:w-auto group-focus/button:w-auto">
|
||||
<SettingsIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-300 ease-in-out group-hover:max-w-[100px] group-hover:opacity-100 group-focus/button:max-w-[100px] group-focus/button:opacity-100">
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export function EndpointItem({ endpoint }: EndpointItemProps) {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
selectedValues,
|
||||
handleOpenKeyDialog,
|
||||
handleSelectEndpoint,
|
||||
endpointSearchValues,
|
||||
setEndpointSearchValue,
|
||||
endpointRequiresUserKey,
|
||||
} = useModelSelectorContext();
|
||||
const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues;
|
||||
|
||||
const searchValue = endpointSearchValues[endpoint.value] || '';
|
||||
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
|
||||
|
||||
const renderIconLabel = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{endpoint.icon && (
|
||||
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
isUserProvided ? 'group-hover:w-24 group-focus:w-24' : '',
|
||||
)}
|
||||
>
|
||||
{endpoint.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (endpoint.hasModels) {
|
||||
const filteredModels = searchValue
|
||||
? filterModels(endpoint, endpoint.models || [], searchValue, agentsMap, assistantsMap)
|
||||
: null;
|
||||
const placeholder =
|
||||
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)
|
||||
? localize('com_endpoint_search_var', { 0: endpoint.label })
|
||||
: localize('com_endpoint_search_endpoint_models', { 0: endpoint.label });
|
||||
return (
|
||||
<Menu
|
||||
id={`endpoint-${endpoint.value}-menu`}
|
||||
key={`endpoint-${endpoint.value}-item`}
|
||||
className="transition-opacity duration-200 ease-in-out"
|
||||
defaultOpen={endpoint.value === selectedEndpoint}
|
||||
searchValue={searchValue}
|
||||
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
|
||||
combobox={<input placeholder={placeholder} />}
|
||||
label={
|
||||
<div
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm"
|
||||
>
|
||||
{renderIconLabel()}
|
||||
{isUserProvided && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
endpoint.models === undefined ? (
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : filteredModels ? (
|
||||
renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
|
||||
) : (
|
||||
endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`endpoint-${endpoint.value}-menu`}
|
||||
key={`endpoint-${endpoint.value}-item`}
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="group flex w-full min-w-0 items-center justify-between">
|
||||
{renderIconLabel()}
|
||||
<div className="flex items-center gap-2">
|
||||
{endpointRequiresUserKey(endpoint.value) && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
)}
|
||||
{selectedEndpoint === endpoint.value && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEndpoints(mappedEndpoints: Endpoint[]) {
|
||||
return mappedEndpoints.map((endpoint) => (
|
||||
<EndpointItem endpoint={endpoint} key={`endpoint-${endpoint.value}-item`} />
|
||||
));
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { Endpoint } from '~/common';
|
||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||
|
||||
interface EndpointModelItemProps {
|
||||
modelId: string | null;
|
||||
endpoint: Endpoint;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
|
||||
const { handleSelectModel } = useModelSelectorContext();
|
||||
let modelName = modelId;
|
||||
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
|
||||
|
||||
// Use custom names if available
|
||||
if (
|
||||
endpoint &&
|
||||
modelId &&
|
||||
endpoint.value === EModelEndpoint.agents &&
|
||||
endpoint.agentNames?.[modelId]
|
||||
) {
|
||||
modelName = endpoint.agentNames[modelId];
|
||||
} else if (
|
||||
endpoint &&
|
||||
modelId &&
|
||||
(endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
endpoint.assistantNames?.[modelId]
|
||||
) {
|
||||
modelName = endpoint.assistantNames[modelId];
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={modelId}
|
||||
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
||||
className="flex h-8 w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{avatarUrl ? (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
) : (endpoint.value === EModelEndpoint.agents ||
|
||||
endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
endpoint.icon ? (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<span>{modelName}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-auto block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderEndpointModels(
|
||||
endpoint: Endpoint | null,
|
||||
models: string[],
|
||||
selectedModel: string | null,
|
||||
filteredModels?: string[],
|
||||
) {
|
||||
const modelsToRender = filteredModels || models;
|
||||
|
||||
return modelsToRender.map(
|
||||
(modelId) =>
|
||||
endpoint && (
|
||||
<EndpointModelItem
|
||||
key={modelId}
|
||||
modelId={modelId}
|
||||
endpoint={endpoint}
|
||||
isSelected={selectedModel === modelId}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import type { TModelSpec } from 'librechat-data-provider';
|
||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||
import SpecIcon from './SpecIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface ModelSpecItemProps {
|
||||
spec: TModelSpec;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
|
||||
const { handleSelectSpec, endpointsConfig } = useModelSelectorContext();
|
||||
const { showIconInMenu = true } = spec;
|
||||
return (
|
||||
<MenuItem
|
||||
key={spec.name}
|
||||
onClick={() => handleSelectSpec(spec)}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer justify-between rounded-lg px-2 text-sm',
|
||||
spec.description ? 'items-start' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 gap-2 px-1 py-1',
|
||||
spec.description ? 'items-start' : 'items-center',
|
||||
)}
|
||||
>
|
||||
{showIconInMenu && (
|
||||
<div className="flex-shrink-0">
|
||||
<SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="truncate text-left">{spec.label}</span>
|
||||
{spec.description && (
|
||||
<span className="break-words text-xs font-normal">{spec.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className={cn('flex-shrink-0', spec.description ? 'pt-1' : '')}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderModelSpecs(specs: TModelSpec[], selectedSpec: string) {
|
||||
if (!specs || specs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return specs.map((spec) => (
|
||||
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
|
||||
));
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TModelSpec } from 'librechat-data-provider';
|
||||
import type { Endpoint } from '~/common';
|
||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||
import SpecIcon from './SpecIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: (TModelSpec | Endpoint)[] | null;
|
||||
localize: (phraseKey: any, options?: any) => string;
|
||||
searchValue: string;
|
||||
}
|
||||
|
||||
export function SearchResults({ results, localize, searchValue }: SearchResultsProps) {
|
||||
const {
|
||||
selectedValues,
|
||||
handleSelectSpec,
|
||||
handleSelectModel,
|
||||
handleSelectEndpoint,
|
||||
endpointsConfig,
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
} = useModelSelectorContext();
|
||||
|
||||
const {
|
||||
modelSpec: selectedSpec,
|
||||
endpoint: selectedEndpoint,
|
||||
model: selectedModel,
|
||||
} = selectedValues;
|
||||
|
||||
if (!results) {
|
||||
return null;
|
||||
}
|
||||
if (!results.length) {
|
||||
return (
|
||||
<div className="cursor-default p-2 sm:py-1 sm:text-sm">
|
||||
{localize('com_files_no_results')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{results.map((item, i) => {
|
||||
if ('name' in item && 'label' in item) {
|
||||
// Render model spec
|
||||
const spec = item as TModelSpec;
|
||||
return (
|
||||
<MenuItem
|
||||
key={spec.name}
|
||||
onClick={() => handleSelectSpec(spec)}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer justify-between rounded-lg px-2 text-sm',
|
||||
spec.description ? 'items-start' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 gap-2 px-1 py-1',
|
||||
spec.description ? 'items-start' : 'items-center',
|
||||
)}
|
||||
>
|
||||
{(spec.showIconInMenu ?? true) && (
|
||||
<div className="flex-shrink-0">
|
||||
<SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="truncate text-left">{spec.label}</span>
|
||||
{spec.description && (
|
||||
<span className="break-words text-xs font-normal">{spec.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedSpec === spec.name && (
|
||||
<div className={cn('flex-shrink-0', spec.description ? 'pt-1' : '')}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
} else {
|
||||
// For an endpoint item
|
||||
const endpoint = item as Endpoint;
|
||||
if (endpoint.hasModels && endpoint.models && endpoint.models.length > 0) {
|
||||
const lowerQuery = searchValue.toLowerCase();
|
||||
const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery)
|
||||
? endpoint.models
|
||||
: endpoint.models.filter((modelId) => {
|
||||
let modelName = modelId;
|
||||
if (
|
||||
endpoint.value === EModelEndpoint.agents &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[modelId]
|
||||
) {
|
||||
modelName = endpoint.agentNames[modelId];
|
||||
} else if (
|
||||
(endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
endpoint.assistantNames &&
|
||||
endpoint.assistantNames[modelId]
|
||||
) {
|
||||
modelName = endpoint.assistantNames[modelId];
|
||||
}
|
||||
return modelName.toLowerCase().includes(lowerQuery);
|
||||
});
|
||||
|
||||
if (!filteredModels.length) {
|
||||
return null; // skip if no models match
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`endpoint-${endpoint.value}-search-${i}`}>
|
||||
<div className="flex items-center gap-2 px-3 py-1 text-sm font-medium">
|
||||
{endpoint.icon && (
|
||||
<div className="flex items-center justify-center overflow-hidden rounded-full p-1">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
)}
|
||||
{endpoint.label}
|
||||
</div>
|
||||
{filteredModels.map((modelId) => {
|
||||
let modelName = modelId;
|
||||
if (
|
||||
endpoint.value === EModelEndpoint.agents &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[modelId]
|
||||
) {
|
||||
modelName = endpoint.agentNames[modelId];
|
||||
} else if (
|
||||
(endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
endpoint.assistantNames &&
|
||||
endpoint.assistantNames[modelId]
|
||||
) {
|
||||
modelName = endpoint.assistantNames[modelId];
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={`${endpoint.value}-${modelId}-search-${i}`}
|
||||
onClick={() => handleSelectModel(endpoint, modelId)}
|
||||
className="flex w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 pl-6 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{endpoint.modelIcons?.[modelId] && (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
<img
|
||||
src={endpoint.modelIcons[modelId]}
|
||||
alt={modelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span>{modelName}</span>
|
||||
</div>
|
||||
{selectedEndpoint === endpoint.value && selectedModel === modelId && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="ml-auto block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
// Endpoints with no models
|
||||
return (
|
||||
<MenuItem
|
||||
key={`endpoint-${endpoint.value}-search-item`}
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
className="flex w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{endpoint.icon && (
|
||||
<div
|
||||
className="flex items-center justify-center overflow-hidden rounded-full border border-gray-200 p-1 dark:border-gray-700"
|
||||
style={{ borderRadius: '50%' }}
|
||||
>
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
)}
|
||||
<span>{endpoint.label}</span>
|
||||
</div>
|
||||
{selectedEndpoint === endpoint.value && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSearchResults(
|
||||
results: (TModelSpec | Endpoint)[] | null,
|
||||
localize: (phraseKey: any, options?: any) => string,
|
||||
searchValue: string,
|
||||
) {
|
||||
return (
|
||||
<SearchResults
|
||||
key={`search-results-${searchValue}`}
|
||||
results={results}
|
||||
localize={localize}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import React, { memo } from 'react';
|
||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
|
||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
||||
interface SpecIconProps {
|
||||
currentSpec: TModelSpec;
|
||||
endpointsConfig: TEndpointsConfig;
|
||||
}
|
||||
|
||||
type IconType = (props: IconMapProps) => React.JSX.Element;
|
||||
|
||||
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) => {
|
||||
const iconURL = getModelSpecIconURL(currentSpec);
|
||||
const { endpoint } = currentSpec.preset;
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
|
||||
let Icon: IconType;
|
||||
|
||||
if (!iconURL.includes('http')) {
|
||||
Icon = (icons[iconKey] ?? icons.unknown) as IconType;
|
||||
} else if (iconURL) {
|
||||
return <URLIcon iconURL={iconURL} altName={currentSpec.name} />;
|
||||
} else {
|
||||
Icon = (icons[endpoint ?? ''] ?? icons.unknown) as IconType;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
size={20}
|
||||
endpoint={endpoint}
|
||||
context="menu-item"
|
||||
iconURL={endpointIconURL}
|
||||
className="icon-md shrink-0 text-text-primary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SpecIcon);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './ModelSpecItem';
|
||||
export * from './EndpointModelItem';
|
||||
export * from './EndpointItem';
|
||||
export * from './SearchResults';
|
||||
162
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
162
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type {
|
||||
TAgentsMap,
|
||||
TAssistantsMap,
|
||||
TEndpointsConfig,
|
||||
TModelSpec,
|
||||
} from 'librechat-data-provider';
|
||||
import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon';
|
||||
import { Endpoint, SelectedValues } from '~/common';
|
||||
|
||||
export function filterItems<
|
||||
T extends { label: string; name?: string; value?: string; models?: string[] },
|
||||
>(
|
||||
items: T[],
|
||||
searchValue: string,
|
||||
agentsMap: TAgentsMap | undefined,
|
||||
assistantsMap: TAssistantsMap | undefined,
|
||||
): T[] | null {
|
||||
const searchTermLower = searchValue.trim().toLowerCase();
|
||||
if (!searchTermLower) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
const itemMatches =
|
||||
item.label.toLowerCase().includes(searchTermLower) ||
|
||||
(item.name && item.name.toLowerCase().includes(searchTermLower)) ||
|
||||
(item.value && item.value.toLowerCase().includes(searchTermLower));
|
||||
|
||||
if (itemMatches) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.models && item.models.length > 0) {
|
||||
return item.models.some((modelId) => {
|
||||
if (modelId.toLowerCase().includes(searchTermLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.value === EModelEndpoint.agents && agentsMap && modelId in agentsMap) {
|
||||
const agentName = agentsMap[modelId]?.name;
|
||||
return typeof agentName === 'string' && agentName.toLowerCase().includes(searchTermLower);
|
||||
}
|
||||
|
||||
if (
|
||||
(item.value === EModelEndpoint.assistants ||
|
||||
item.value === EModelEndpoint.azureAssistants) &&
|
||||
assistantsMap
|
||||
) {
|
||||
const endpoint = item.value;
|
||||
const assistant = assistantsMap[endpoint][modelId];
|
||||
if (assistant && typeof assistant.name === 'string') {
|
||||
return assistant.name.toLowerCase().includes(searchTermLower);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterModels(
|
||||
endpoint: Endpoint,
|
||||
models: string[],
|
||||
searchValue: string,
|
||||
agentsMap: TAgentsMap | undefined,
|
||||
assistantsMap: TAssistantsMap | undefined,
|
||||
): string[] {
|
||||
const searchTermLower = searchValue.trim().toLowerCase();
|
||||
if (!searchTermLower) {
|
||||
return models;
|
||||
}
|
||||
|
||||
return models.filter((modelId) => {
|
||||
let modelName = modelId;
|
||||
|
||||
if (endpoint.value === EModelEndpoint.agents && agentsMap && agentsMap[modelId]) {
|
||||
modelName = agentsMap[modelId].name || modelId;
|
||||
} else if (
|
||||
(endpoint.value === EModelEndpoint.assistants ||
|
||||
endpoint.value === EModelEndpoint.azureAssistants) &&
|
||||
assistantsMap &&
|
||||
assistantsMap[endpoint.value]
|
||||
) {
|
||||
const assistant = assistantsMap[endpoint.value][modelId];
|
||||
modelName =
|
||||
typeof assistant.name === 'string' && assistant.name ? (assistant.name as string) : modelId;
|
||||
}
|
||||
|
||||
return modelName.toLowerCase().includes(searchTermLower);
|
||||
});
|
||||
}
|
||||
|
||||
export function getSelectedIcon({
|
||||
mappedEndpoints,
|
||||
selectedValues,
|
||||
modelSpecs,
|
||||
endpointsConfig,
|
||||
}: {
|
||||
mappedEndpoints: Endpoint[];
|
||||
selectedValues: SelectedValues;
|
||||
modelSpecs: TModelSpec[];
|
||||
endpointsConfig: TEndpointsConfig;
|
||||
}): React.ReactNode | null {
|
||||
const { endpoint, model, modelSpec } = selectedValues;
|
||||
|
||||
if (modelSpec) {
|
||||
const spec = modelSpecs.find((s) => s.name === modelSpec);
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
const { showIconInHeader = true } = spec;
|
||||
if (!showIconInHeader) {
|
||||
return null;
|
||||
}
|
||||
return React.createElement(SpecIcon, {
|
||||
currentSpec: spec,
|
||||
endpointsConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (endpoint && model) {
|
||||
const selectedEndpoint = mappedEndpoints.find((e) => e.value === endpoint);
|
||||
if (!selectedEndpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedEndpoint.modelIcons?.[model]) {
|
||||
const iconUrl = selectedEndpoint.modelIcons[model];
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'h-5 w-5 overflow-hidden rounded-full' },
|
||||
React.createElement('img', {
|
||||
src: iconUrl,
|
||||
alt: model,
|
||||
className: 'h-full w-full object-cover',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
selectedEndpoint.icon ||
|
||||
React.createElement(Bot, {
|
||||
size: 20,
|
||||
className: 'icon-md shrink-0 text-text-primary',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (endpoint) {
|
||||
const selectedEndpoint = mappedEndpoints.find((e) => e.value === endpoint);
|
||||
return selectedEndpoint?.icon || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue