Merge branch 'main' into feat/multi-lang-Terms-of-service

This commit is contained in:
Ruben Talstra 2025-04-05 15:09:43 +02:00 committed by GitHub
commit 7c0324695a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
258 changed files with 8260 additions and 3717 deletions

View file

@ -12,7 +12,7 @@ function AddMultiConvo() {
const localize = useLocalize();
const clickHandler = () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
setAddedConvo({
...convo,
@ -42,7 +42,7 @@ function AddMultiConvo() {
role="button"
onClick={clickHandler}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<PlusCircle size={16} aria-label="Plus Icon" />
</TooltipAnchor>

View file

@ -7,6 +7,7 @@ import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import MessagesView from './Messages/MessagesView';
import { Spinner } from '~/components/svg';
import Presentation from './Presentation';
@ -21,6 +22,7 @@ function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const addedSubmission = useRecoilValue(store.submissionByIndex(index + 1));
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const fileMap = useFileMapContext();
@ -46,16 +48,20 @@ function ChatView({ index = 0 }: { index?: number }) {
});
let content: JSX.Element | null | undefined;
const isLandingPage = !messagesTree || messagesTree.length === 0;
if (isLoading && conversationId !== 'new') {
content = (
<div className="flex h-screen items-center justify-center">
<Spinner className="opacity-0" />
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
} else if (messagesTree && messagesTree.length !== 0) {
content = <MessagesView messagesTree={messagesTree} Header={<Header />} />;
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
} else {
content = <Landing Header={<Header />} />;
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
}
return (
@ -63,10 +69,29 @@ function ChatView({ index = 0 }: { index?: number }) {
<ChatContext.Provider value={chatHelpers}>
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
{content}
<div className="w-full border-t-0 pl-0 pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<ChatForm index={index} />
<Footer />
<div className="flex h-full w-full flex-col">
{!isLoading && <Header />}
{isLandingPage ? (
<>
<div className="flex flex-1 flex-col items-center justify-end sm:justify-center">
{content}
<div className="w-full max-w-3xl transition-all duration-200 xl:max-w-4xl">
<ChatForm index={index} />
<ConversationStarters />
</div>
</div>
<Footer />
</>
) : (
<div className="flex h-full flex-col overflow-y-auto">
{content}
<div className="w-full">
<ChatForm index={index} />
<Footer />
</div>
</div>
)}
</div>
</Presentation>
</AddedChatContext.Provider>

View file

@ -1,17 +0,0 @@
interface ConvoStarterProps {
text: string;
onClick: () => void;
}
export default function ConvoStarter({ text, onClick }: ConvoStarterProps) {
return (
<button
onClick={onClick}
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
>
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
{text}
</p>
</button>
);
}

View file

@ -79,7 +79,7 @@ export default function ExportAndShareMenu({
<Ariakit.MenuButton
id="export-menu-button"
aria-label="Export options"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Upload
className="icon-md text-text-secondary"
@ -103,7 +103,6 @@ export default function ExportAndShareMenu({
<ShareButton
triggerRef={shareButtonRef}
conversationId={conversation.conversationId ?? ''}
title={conversation.title ?? ''}
open={showShareDialog}
onOpenChange={setShowShareDialog}
/>

View file

@ -56,7 +56,6 @@ export default function Footer({ className }: { className?: string }) {
<React.Fragment key={`main-content-part-${index}`}>
<ReactMarkdown
components={{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
a: ({ node: _n, href, children, ...otherProps }) => {
return (
<a
@ -70,7 +69,7 @@ export default function Footer({ className }: { className?: string }) {
</a>
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node: _n, ...props }) => <span {...props} />,
}}
>
@ -84,24 +83,29 @@ export default function Footer({ className }: { className?: string }) {
);
return (
<div
className={
className ??
'relative flex items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary md:px-[60px]'
}
role="contentinfo"
>
{footerElements.map((contentRender, index) => {
const isLastElement = index === footerElements.length - 1;
return (
<React.Fragment key={`footer-element-${index}`}>
{contentRender}
{!isLastElement && (
<div key={`separator-${index}`} className="h-2 border-r-[1px] border-border-medium" />
)}
</React.Fragment>
);
})}
<div className="relative w-full">
<div
className={
className ??
'absolute bottom-0 left-0 right-0 hidden items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary sm:flex md:px-[60px]'
}
role="contentinfo"
>
{footerElements.map((contentRender, index) => {
const isLastElement = index === footerElements.length - 1;
return (
<React.Fragment key={`footer-element-${index}`}>
{contentRender}
{!isLastElement && (
<div
key={`separator-${index}`}
className="h-2 border-r-[1px] border-border-medium"
/>
)}
</React.Fragment>
);
})}
</div>
</div>
);
}

View file

@ -2,12 +2,13 @@ import { useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import ModelSelector from './Menus/Endpoints/ModelSelector';
import { PresetsMenu, HeaderNewChat } from './Menus';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import { useMediaQuery, useHasAccess } from '~/hooks';
import HeaderOptions from './Input/HeaderOptions';
import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
const defaultInterface = getConfigDefaults().interface;
@ -15,7 +16,6 @@ const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible } = useOutletContext<ContextType>();
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
@ -34,24 +34,30 @@ export default function Header() {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold dark:bg-gray-800 dark:text-white">
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="flex items-center gap-2">
<div className="mx-2 flex items-center gap-2">
{!navVisible && <HeaderNewChat />}
{interfaceConfig.endpointsMenu === true && <EndpointsMenu />}
{modelSpecs.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
{<HeaderOptions interfaceConfig={interfaceConfig} />}
{interfaceConfig.presets === true && <PresetsMenu />}
{<ModelSelector startupConfig={startupConfig} />}
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}
{hasAccessToMultiConvo === true && <AddMultiConvo />}
{isSmallScreen && (
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<>
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<TemporaryChat />
</>
)}
</div>
{!isSmallScreen && (
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />
<div className="flex items-center gap-2">
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<TemporaryChat />
</div>
)}
</div>
{/* Empty div for spacing */}

View file

@ -7,14 +7,12 @@ import { globalAudioId } from '~/common';
import { cn } from '~/utils';
export default function AudioRecorder({
isRTL,
disabled,
ask,
methods,
textAreaRef,
isSubmitting,
}: {
isRTL: boolean;
disabled: boolean;
ask: (data: { text: string }) => void;
methods: ReturnType<typeof useChatFormContext>;
@ -90,9 +88,7 @@ export default function AudioRecorder({
onClick={isListening === true ? handleStopRecording : handleStartRecording}
disabled={disabled}
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
isRTL ? 'bottom-2 left-2' : 'bottom-2 right-2',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover',
)}
title={localize('com_ui_use_micrphone')}
aria-pressed={isListening}

View file

@ -0,0 +1,369 @@
import React, {
useState,
useRef,
useEffect,
useCallback,
useMemo,
forwardRef,
useReducer,
} from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import type { BadgeItem } from '~/common';
import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import store from '~/store';
interface BadgeRowProps {
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void;
isInChat: boolean;
}
interface BadgeWrapperProps {
badge: BadgeItem;
isEditing: boolean;
isInChat: boolean;
onToggle: (badge: BadgeItem) => void;
onDelete: (id: string) => void;
onMouseDown: (e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => void;
badgeRefs: React.MutableRefObject<Record<string, HTMLDivElement>>;
}
const BadgeWrapper = React.memo(
forwardRef<HTMLDivElement, BadgeWrapperProps>(
({ badge, isEditing, isInChat, onToggle, onDelete, onMouseDown, badgeRefs }, ref) => {
const isActive = badge.atom ? useRecoilValue(badge.atom) : false;
return (
<div
ref={(el) => {
if (el) {
badgeRefs.current[badge.id] = el;
}
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
onMouseDown={(e) => onMouseDown(e, badge, isActive)}
className={isEditing ? 'ios-wiggle badge-icon h-full' : 'badge-icon h-full'}
>
<Badge
id={badge.id}
icon={badge.icon as LucideIcon}
label={badge.label}
isActive={isActive}
isEditing={isEditing}
isAvailable={badge.isAvailable}
isInChat={isInChat}
onToggle={() => onToggle(badge)}
onBadgeAction={() => onDelete(badge.id)}
/>
</div>
);
},
),
(prevProps, nextProps) =>
prevProps.badge.id === nextProps.badge.id &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.isInChat === nextProps.isInChat &&
prevProps.onToggle === nextProps.onToggle &&
prevProps.onDelete === nextProps.onDelete &&
prevProps.onMouseDown === nextProps.onMouseDown &&
prevProps.badgeRefs === nextProps.badgeRefs,
);
BadgeWrapper.displayName = 'BadgeWrapper';
interface DragState {
draggedBadge: BadgeItem | null;
mouseX: number;
offsetX: number;
insertIndex: number | null;
draggedBadgeActive: boolean;
}
type DragAction =
| {
type: 'START_DRAG';
badge: BadgeItem;
mouseX: number;
offsetX: number;
insertIndex: number;
isActive: boolean;
}
| { type: 'UPDATE_POSITION'; mouseX: number; insertIndex: number }
| { type: 'END_DRAG' };
const dragReducer = (state: DragState, action: DragAction): DragState => {
switch (action.type) {
case 'START_DRAG':
return {
draggedBadge: action.badge,
mouseX: action.mouseX,
offsetX: action.offsetX,
insertIndex: action.insertIndex,
draggedBadgeActive: action.isActive,
};
case 'UPDATE_POSITION':
return {
...state,
mouseX: action.mouseX,
insertIndex: action.insertIndex,
};
case 'END_DRAG':
return {
draggedBadge: null,
mouseX: 0,
offsetX: 0,
insertIndex: null,
draggedBadgeActive: false,
};
default:
return state;
}
};
export function BadgeRow({ onChange, onToggle, isInChat }: BadgeRowProps) {
const [orderedBadges, setOrderedBadges] = useState<BadgeItem[]>([]);
const [dragState, dispatch] = useReducer(dragReducer, {
draggedBadge: null,
mouseX: 0,
offsetX: 0,
insertIndex: null,
draggedBadgeActive: false,
});
const badgeRefs = useRef<Record<string, HTMLDivElement>>({});
const containerRef = useRef<HTMLDivElement>(null);
const animationFrame = useRef<number | null>(null);
const containerRectRef = useRef<DOMRect | null>(null);
const allBadges = useChatBadges() || [];
const isEditing = useRecoilValue(store.isEditingBadges);
const badges = useMemo(
() => allBadges.filter((badge) => badge.isAvailable !== false),
[allBadges],
);
const toggleBadge = useRecoilCallback(
({ snapshot, set }) =>
async (badgeAtom: any) => {
const current = await snapshot.getPromise(badgeAtom);
set(badgeAtom, !current);
},
[],
);
useEffect(() => {
setOrderedBadges((prev) => {
const currentIds = new Set(prev.map((b) => b.id));
const newBadges = badges.filter((b) => !currentIds.has(b.id));
return newBadges.length > 0 ? [...prev, ...newBadges] : prev;
});
}, [badges]);
const tempBadges = dragState.draggedBadge
? orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id)
: orderedBadges;
const ghostBadge = dragState.draggedBadge || null;
const calculateInsertIndex = useCallback(
(currentMouseX: number): number => {
if (!dragState.draggedBadge || !containerRef.current || !containerRectRef.current) {
return 0;
}
const relativeMouseX = currentMouseX - containerRectRef.current.left;
const refs = tempBadges.map((b) => badgeRefs.current[b.id]).filter(Boolean);
if (refs.length === 0) {
return 0;
}
let idx = 0;
for (let i = 0; i < refs.length; i++) {
const rect = refs[i].getBoundingClientRect();
const relativeLeft = rect.left - containerRectRef.current.left;
const relativeCenter = relativeLeft + rect.width / 2;
if (relativeMouseX < relativeCenter) {
break;
}
idx = i + 1;
}
return idx;
},
[dragState.draggedBadge, tempBadges],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent, badge: BadgeItem, isActive: boolean) => {
if (!isEditing || !containerRef.current) {
return;
}
const el = badgeRefs.current[badge.id];
if (!el) {
return;
}
const rect = el.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const mouseX = e.clientX;
const initialIndex = orderedBadges.findIndex((b) => b.id === badge.id);
containerRectRef.current = containerRef.current.getBoundingClientRect();
dispatch({
type: 'START_DRAG',
badge,
mouseX,
offsetX,
insertIndex: initialIndex,
isActive,
});
},
[isEditing, orderedBadges],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragState.draggedBadge) {
return;
}
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
animationFrame.current = requestAnimationFrame(() => {
const newMouseX = e.clientX;
const newInsertIndex = calculateInsertIndex(newMouseX);
if (newInsertIndex !== dragState.insertIndex) {
dispatch({ type: 'UPDATE_POSITION', mouseX: newMouseX, insertIndex: newInsertIndex });
} else {
dispatch({
type: 'UPDATE_POSITION',
mouseX: newMouseX,
insertIndex: dragState.insertIndex,
});
}
});
},
[dragState.draggedBadge, dragState.insertIndex, calculateInsertIndex],
);
const handleMouseUp = useCallback(() => {
if (dragState.draggedBadge && dragState.insertIndex !== null) {
const otherBadges = orderedBadges.filter((b) => b.id !== dragState.draggedBadge?.id);
const newBadges = [
...otherBadges.slice(0, dragState.insertIndex),
dragState.draggedBadge,
...otherBadges.slice(dragState.insertIndex),
];
setOrderedBadges(newBadges);
onChange(newBadges.map((badge) => ({ id: badge.id })));
}
dispatch({ type: 'END_DRAG' });
containerRectRef.current = null;
}, [dragState.draggedBadge, dragState.insertIndex, orderedBadges, onChange]);
const handleDelete = useCallback(
(badgeId: string) => {
const newBadges = orderedBadges.filter((b) => b.id !== badgeId);
setOrderedBadges(newBadges);
onChange(newBadges.map((badge) => ({ id: badge.id })));
},
[orderedBadges, onChange],
);
const handleBadgeToggle = useCallback(
(badge: BadgeItem) => {
if (badge.atom) {
toggleBadge(badge.atom);
}
if (onToggle) {
onToggle(badge.id, !!badge.atom);
}
},
[toggleBadge, onToggle],
);
useEffect(() => {
if (!dragState.draggedBadge) {
return;
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
animationFrame.current = null;
}
};
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
return (
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
);
}

View file

@ -1,11 +1,7 @@
import { memo, useRef, useMemo, useEffect, useState } from 'react';
import { memo, useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { useWatch } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { Constants, isAssistantsEndpoint } from 'librechat-data-provider';
import {
useChatContext,
useChatFormContext,
@ -20,47 +16,107 @@ import {
useQueryParams,
useSubmitMessage,
} from '~/hooks';
import { cn, removeFocusRings, checkIfScrollable } from '~/utils';
import FileFormWrapper from './Files/FileFormWrapper';
import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { TemporaryChat } from './TemporaryChat';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
import FileFormChat from './Files/FileFormChat';
import { TextareaAutosize } from '~/components';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
import CollapseChat from './CollapseChat';
import StreamAudio from './StreamAudio';
import StopButton from './StopButton';
import SendButton from './SendButton';
import { BadgeRow } from './BadgeRow';
import EditBadges from './EditBadges';
import Mention from './Mention';
import store from '~/store';
const ChatForm = ({ index = 0 }) => {
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
useQueryParams({ textAreaRef });
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [isScrollable, setIsScrollable] = useState(false);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const [isTemporaryChat, setIsTemporaryChat] = useRecoilState<boolean>(store.isTemporary);
const [, setIsScrollable] = useState(false);
const [visualRowCount, setVisualRowCount] = useState(1);
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const isSearching = useRecoilValue(store.isSearching);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const chatDirection = useRecoilValue(store.chatDirection);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const isTemporary = useRecoilValue(store.isTemporary);
const [badges, setBadges] = useRecoilState(store.chatBadges);
const [isEditingBadges, setIsEditingBadges] = useRecoilState(store.isEditingBadges);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
const [showPlusPopover, setShowPlusPopover] = useRecoilState(store.showPlusPopoverFamily(index));
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
store.showMentionPopoverFamily(index),
);
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const { requiresKey } = useRequiresKey();
const methods = useChatFormContext();
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const assistantMap = useAssistantsMapContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
const endpoint = useMemo(
() => conversation?.endpointType ?? conversation?.endpoint,
[conversation?.endpointType, conversation?.endpoint],
);
const isRTL = useMemo(() => chatDirection === 'rtl', [chatDirection.toLowerCase()]);
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(endpoint) &&
(!(conversation?.assistant_id ?? '') ||
!assistantMap?.[endpoint ?? '']?.[conversation?.assistant_id ?? '']),
[conversation?.assistant_id, endpoint, assistantMap],
);
const disableInputs = useMemo(
() => requiresKey || invalidAssistant,
[requiresKey, invalidAssistant],
);
const handleContainerClick = useCallback(() => {
textAreaRef.current?.focus();
}, []);
const handleFocusOrClick = useCallback(() => {
if (isCollapsed) {
setIsCollapsed(false);
}
}, [isCollapsed]);
useAutoSave({
conversationId: conversation?.conversationId,
textAreaRef,
files,
setFiles,
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
@ -71,65 +127,22 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef,
submitButtonRef,
setIsScrollable,
disabled: !!(requiresKey ?? false),
disabled: disableInputs,
});
const {
files,
setFiles,
conversation,
isSubmitting,
filesLoading,
newConversation,
handleStopGenerating,
} = useChatContext();
const methods = useChatFormContext();
const {
addedIndex,
generateConversation,
conversation: addedConvo,
setConversation: setAddedConvo,
isSubmitting: isSubmittingAdded,
} = useAddedChatContext();
const showStopAdded = useRecoilValue(store.showStopButtonByIndex(addedIndex));
useAutoSave({
conversationId: useMemo(() => conversation?.conversationId, [conversation]),
textAreaRef,
files,
setFiles,
});
const assistantMap = useAssistantsMapContext();
const { submitMessage, submitPrompt } = useSubmitMessage();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
const invalidAssistant = useMemo(
() =>
isAssistantsEndpoint(conversation?.endpoint) &&
(!(conversation?.assistant_id ?? '') ||
!assistantMap?.[conversation?.endpoint ?? ''][conversation?.assistant_id ?? '']),
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
);
const disableInputs = useMemo(
() => !!((requiresKey ?? false) || invalidAssistant),
[requiresKey, invalidAssistant],
);
useQueryParams({ textAreaRef });
const { ref, ...registerProps } = methods.register('text', {
required: true,
onChange: (e) => {
methods.setValue('text', e.target.value, { shouldValidate: true });
},
onChange: useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
methods.setValue('text', e.target.value, { shouldValidate: true }),
[methods],
),
});
const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
@ -138,33 +151,58 @@ const ChatForm = ({ index = 0 }) => {
useEffect(() => {
if (textAreaRef.current) {
checkIfScrollable(textAreaRef.current);
const style = window.getComputedStyle(textAreaRef.current);
const lineHeight = parseFloat(style.lineHeight);
setVisualRowCount(Math.floor(textAreaRef.current.scrollHeight / lineHeight));
}
}, [textValue]);
useEffect(() => {
if (isEditingBadges && backupBadges.length === 0) {
setBackupBadges([...badges]);
}
}, [isEditingBadges, badges, backupBadges.length]);
const handleSaveBadges = useCallback(() => {
setIsEditingBadges(false);
setBackupBadges([]);
}, []);
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
const isUploadDisabled: boolean = endpointFileConfig?.disabled ?? false;
const handleCancelBadges = useCallback(() => {
if (backupBadges.length > 0) {
setBadges([...backupBadges]);
}
setIsEditingBadges(false);
setBackupBadges([]);
}, [backupBadges, setBadges]);
const baseClasses = cn(
'md:py-3.5 m-0 w-full resize-none py-[13px] bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
const isMoreThanThreeRows = visualRowCount > 3;
const baseClasses = useMemo(
() =>
cn(
'md:py-3.5 m-0 w-full resize-none py-[13px] placeholder-black/50 bg-transparent dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[45vh] md:max-h-[55vh]',
isMoreThanThreeRows ? 'pl-5' : 'px-5',
),
[isCollapsed, isMoreThanThreeRows],
);
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
const speechClass = isRTL
? `pr-${uploadActive ? '12' : '4'} pl-12`
: `pl-${uploadActive ? '12' : '4'} pr-12`;
return (
<form
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
onSubmit={methods.handleSubmit(submitMessage)}
className={cn(
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
'mx-auto flex flex-row gap-3 sm:px-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
centerFormOnLanding &&
(!conversation?.conversationId || conversation?.conversationId === Constants.NEW_CONVO) &&
!isSubmitting
? 'transition-all duration-200 sm:mb-28'
: 'sm:mb-10',
)}
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
@ -183,90 +221,109 @@ const ChatForm = ({ index = 0 }) => {
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TemporaryChat
isTemporaryChat={isTemporaryChat}
setIsTemporaryChat={setIsTemporaryChat}
/>
<div
onClick={handleContainerClick}
className={cn(
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl border pb-4 text-text-primary transition-all duration-200 sm:rounded-3xl sm:pb-0',
isTextAreaFocused ? 'shadow-lg' : 'shadow-md',
isTemporary
? 'border-violet-800/60 bg-violet-950/10'
: 'border-border-light bg-surface-chat',
)}
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
<>
<EditBadges
isEditingChatBadges={isEditingBadges}
handleCancelBadges={handleCancelBadges}
handleSaveBadges={handleSaveBadges}
setBadges={setBadges}
/>
<FileFormChat disableInputs={disableInputs} />
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => {
handleFocusOrClick();
setIsTextAreaFocused(true);
}}
onBlur={setIsTextAreaFocused.bind(null, false)}
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isScrollable}
isScrollable={isMoreThanThreeRows}
setIsCollapsed={setIsCollapsed}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onHeightChange={() => {
if (textAreaRef.current) {
const scrollable = checkIfScrollable(textAreaRef.current);
setIsScrollable(scrollable);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => isCollapsed && setIsCollapsed(false)}
onClick={() => isCollapsed && setIsCollapsed(false)}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
speechClass,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
</>
</div>
</div>
)}
<div
className={cn(
'items-between flex gap-2 pb-2',
isRTL ? 'flex-row-reverse' : 'flex-row',
)}
</FileFormWrapper>
{SpeechToText && (
<AudioRecorder
isRTL={isRTL}
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
>
<div className={`${isRTL ? 'mr-2' : 'ml-2'}`}>
<AttachFileChat disableInputs={disableInputs} />
</div>
<BadgeRow
onChange={(newBadges) => setBadges(newBadges)}
isInChat={
Array.isArray(conversation?.messages) && conversation.messages.length >= 1
}
/>
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
<div
className={cn(
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
isRTL && 'order-first mr-[8px]',
)}
style={{ alignSelf: 'flex-end' }}
>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
<div className="mx-auto flex" />
{SpeechToText && (
<AudioRecorder
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={disableInputs}
isSubmitting={isSubmitting}
/>
)
)}
)}
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs}
/>
)
)}
</div>
</div>
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
</div>
</div>
</form>
);
};
});
export default memo(ChatForm);
export default ChatForm;

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Minimize2 } from 'lucide-react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -18,23 +18,37 @@ const CollapseChat = ({
return null;
}
if (isCollapsed) {
return null;
}
const description = isCollapsed
? localize('com_ui_expand_chat')
: localize('com_ui_collapse_chat');
return (
<TooltipAnchor
role="button"
description={localize('com_ui_collapse_chat')}
aria-label={localize('com_ui_collapse_chat')}
onClick={() => setIsCollapsed(true)}
className={cn(
'absolute right-2 top-2 z-10 size-[35px] rounded-full p-2 transition-colors',
'hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
>
<Minimize2 className="h-full w-full" />
</TooltipAnchor>
<div className="relative ml-auto items-end justify-end">
<TooltipAnchor
description={description}
render={
<button
aria-label={description}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsCollapsed((prev) => !prev);
}}
className={cn(
// 'absolute right-1.5 top-1.5',
'z-10 size-5 rounded-full transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
)}
>
{isCollapsed ? (
<ChevronDown className="h-full w-full" />
) : (
<ChevronUp className="h-full w-full" />
)}
</button>
}
/>
</div>
);
};

View file

@ -0,0 +1,85 @@
import { useMemo, useCallback } from 'react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint, getEntity } from '~/utils';
import { useSubmitMessage } from '~/hooks';
const ConversationStarters = () => {
const { conversation } = useChatContext();
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const endpointType = useMemo(() => {
let ep = conversation?.endpoint ?? '';
if (
[
EModelEndpoint.chatGPTBrowser,
EModelEndpoint.azureOpenAI,
EModelEndpoint.gptPlugins,
].includes(ep as EModelEndpoint)
) {
ep = EModelEndpoint.openAI;
}
return getIconEndpoint({
endpointsConfig,
iconURL: conversation?.iconURL,
endpoint: ep,
});
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpointType, {
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
});
const { entity, isAgent } = getEntity({
endpoint: endpointType,
agentsMap,
assistantMap,
agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id,
});
const conversation_starters = useMemo(() => {
if (entity?.conversation_starters?.length) {
return entity.conversation_starters;
}
if (isAgent) {
return [];
}
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
}, [documentsMap, isAgent, entity]);
const { submitMessage } = useSubmitMessage();
const sendConversationStarter = useCallback(
(text: string) => submitMessage({ text }),
[submitMessage],
);
if (!conversation_starters.length) {
return null;
}
return (
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
{conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
<button
key={index}
onClick={() => sendConversationStarter(text)}
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
>
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
{text}
</p>
</button>
))}
</div>
);
};
export default ConversationStarters;

View file

@ -0,0 +1,87 @@
import React, { useCallback } from 'react';
import { Edit3, Check, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { BadgeItem } from '~/common';
import { useChatBadges, useLocalize } from '~/hooks';
import { Button, Badge } from '~/components/ui';
interface EditBadgesProps {
isEditingChatBadges: boolean;
handleCancelBadges: () => void;
handleSaveBadges: () => void;
setBadges: React.Dispatch<React.SetStateAction<Pick<BadgeItem, 'id'>[]>>;
}
const EditBadgesComponent = ({
isEditingChatBadges,
handleCancelBadges,
handleSaveBadges,
setBadges,
}: EditBadgesProps) => {
const localize = useLocalize();
const allBadges = useChatBadges() || [];
const unavailableBadges = allBadges.filter((badge) => !badge.isAvailable);
const handleRestoreBadge = useCallback(
(badgeId: string) => {
setBadges((prev: Pick<BadgeItem, 'id'>[]) => [...prev, { id: badgeId }]);
},
[setBadges],
);
if (!isEditingChatBadges) {
return null;
}
return (
<div className="m-1.5 flex flex-col overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="flex items-center gap-4 py-2 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex size-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<Edit3 className="icon-md" aria-hidden="true" />
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{localize('com_ui_save_badge_changes')}
</span>
<div className="flex h-8 gap-2">
<Button
size="sm"
variant="destructive"
aria-label="Cancel"
onClick={handleCancelBadges}
className="h-8"
>
<X className="icon-md" aria-hidden="true" />
</Button>
<Button
size="sm"
variant="submit"
aria-label="Save changes"
onClick={handleSaveBadges}
className="h-8 rounded-b-lg rounded-tr-xl"
>
<Check className="icon-md" aria-hidden="true" />
</Button>
</div>
</div>
{unavailableBadges && unavailableBadges.length > 0 && (
<div className="flex flex-wrap items-center gap-2 p-2">
{unavailableBadges.map((badge) => (
<div key={badge.id} className="badge-icon">
<Badge
icon={badge.icon as unknown as LucideIcon}
label={badge.label}
isAvailable={false}
isEditing={true}
onBadgeAction={() => handleRestoreBadge(badge.id)}
/>
</div>
))}
</div>
)}
</div>
);
};
export default React.memo(EditBadgesComponent);

View file

@ -1,55 +1,52 @@
import React, { useRef } from 'react';
import { FileUpload, TooltipAnchor } from '~/components/ui';
import { AttachmentIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { FileUpload, TooltipAnchor, AttachmentIcon } from '~/components';
import { useLocalize, useFileHandling } from '~/hooks';
import { cn } from '~/utils';
const AttachFile = ({
isRTL,
disabled,
handleFileChange,
}: {
isRTL: boolean;
disabled?: boolean | null;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}) => {
const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
const localize = useLocalize();
const inputRef = useRef<HTMLInputElement>(null);
const isUploadDisabled = disabled ?? false;
const { handleFileChange } = useFileHandling();
return (
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
<TooltipAnchor
role="button"
id="attach-file"
aria-label={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-2',
)}
description={localize('com_sidepanel_attach_files')}
onKeyDownCapture={(e) => {
if (!inputRef.current) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
inputRef.current.value = '';
inputRef.current.click();
}
}}
onClick={() => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.click();
}}
>
<div className="flex w-full items-center justify-center gap-2">
<AttachmentIcon />
</div>
</TooltipAnchor>
id="attach-file"
disabled={isUploadDisabled}
render={
<button
type="button"
aria-label={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
onKeyDownCapture={(e) => {
if (!inputRef.current) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
inputRef.current.value = '';
inputRef.current.click();
}
}}
onClick={() => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.click();
}}
>
<div className="flex w-full items-center justify-center gap-2">
<AttachmentIcon />
</div>
</button>
}
/>
</FileUpload>
);
};

View file

@ -0,0 +1,44 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
import store from '~/store';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents) {
return <AttachFileMenu disabled={disableInputs} />;
}
if (endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;
}
return null;
}
export default memo(AttachFileChat);

View file

@ -2,25 +2,23 @@ import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { FileUpload, TooltipAnchor, DropdownPopup } from '~/components/ui';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { useGetEndpointsQuery } from '~/data-provider';
import { AttachmentIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { useLocalize, useFileHandling } from '~/hooks';
import { cn } from '~/utils';
interface AttachFileProps {
isRTL: boolean;
disabled?: boolean | null;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
}
const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
const AttachFile = ({ disabled }: AttachFileProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling();
const capabilities = useMemo(
() => endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? [],
@ -93,8 +91,7 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
id="attach-file-menu-button"
aria-label="Attach File Options"
className={cn(
'absolute flex size-[35px] items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
isRTL ? 'bottom-2 right-2' : 'bottom-2 left-1 md:left-2',
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
>
<div className="flex w-full items-center justify-center gap-2">
@ -115,17 +112,15 @@ const AttachFile = ({ isRTL, disabled, handleFileChange }: AttachFileProps) => {
handleFileChange(e, toolResource);
}}
>
<div className="relative select-none">
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</div>
<DropdownPopup
menuId="attach-file-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
</FileUpload>
);
};

View file

@ -0,0 +1,30 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import FileRow from './FileRow';
import store from '~/store';
function FileFormChat({ disableInputs }: { disableInputs: boolean }) {
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
const { endpoint: _endpoint } = conversation ?? { endpoint: null };
const { abortUpload } = useFileHandling();
const isRTL = chatDirection === 'rtl';
return (
<>
<FileRow
files={files}
setFiles={setFiles}
abortUpload={abortUpload}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
/>
</>
);
}
export default memo(FileFormChat);

View file

@ -1,80 +0,0 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers';
import { useFileHandling } from '~/hooks';
import AttachFile from './AttachFile';
import FileRow from './FileRow';
import store from '~/store';
function FileFormWrapper({
children,
disableInputs,
}: {
disableInputs: boolean;
children?: React.ReactNode;
}) {
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const { files, setFiles, conversation, setFilesLoading } = useChatContext();
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { handleFileChange, abortUpload } = useFileHandling();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const isRTL = chatDirection === 'rtl';
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
| EndpointFileConfig
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
const renderAttachFile = () => {
if (isAgents) {
return (
<AttachFileMenu
isRTL={isRTL}
disabled={disableInputs}
handleFileChange={handleFileChange}
/>
);
}
if (endpointSupportsFiles && !isUploadDisabled) {
return (
<AttachFile isRTL={isRTL} disabled={disableInputs} handleFileChange={handleFileChange} />
);
}
return null;
};
return (
<>
<FileRow
files={files}
setFiles={setFiles}
abortUpload={abortUpload}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
Wrapper={({ children }) => <div className="mx-2 mt-2 flex flex-wrap gap-2">{children}</div>}
/>
{children}
{renderAttachFile()}
</>
);
}
export default memo(FileFormWrapper);

View file

@ -1,11 +1,15 @@
import { useLocalize } from '~/hooks';
export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
const localize = useLocalize();
return (
<button
type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary z-50"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary"
onClick={onRemove}
aria-label={localize('com_ui_attach_remove')}
>
<span>
<span aria-hidden="true">
<svg
stroke="currentColor"
fill="none"

View file

@ -10,7 +10,8 @@ const sourceToEndpoint = {
const sourceToClassname = {
[FileSources.openai]: 'bg-white/75 dark:bg-black/65',
[FileSources.azure]: 'azure-bg-color opacity-85',
[FileSources.azure]: 'azure-bg-color',
[FileSources.azure_blob]: 'azure-bg-color',
[FileSources.execute_code]: 'bg-black text-white opacity-85',
[FileSources.text]: 'bg-blue-500 dark:bg-blue-900 opacity-85 text-white',
[FileSources.vectordb]: 'bg-yellow-700 dark:bg-yellow-900 opacity-85 text-white',

View file

@ -2,17 +2,12 @@ import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Root, Anchor } from '@radix-ui/react-popover';
import {
EModelEndpoint,
isParamEndpoint,
isAgentsEndpoint,
tConvoUpdateSchema,
} from 'librechat-data-provider';
import { EModelEndpoint, isParamEndpoint, tConvoUpdateSchema } from 'librechat-data-provider';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { useSetIndexOptions, useMediaQuery, useLocalize } from '~/hooks';
import { PluginStoreDialog, TooltipAnchor } from '~/components';
import { ModelSelect } from '~/components/Input/ModelSelect';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import OptionsPopover from './OptionsPopover';
import PopoverButtons from './PopoverButtons';
@ -26,6 +21,7 @@ export default function HeaderOptions({
interfaceConfig?: Partial<TInterfaceConfig>;
}) {
const { data: endpointsConfig } = useGetEndpointsQuery();
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
@ -35,6 +31,15 @@ export default function HeaderOptions({
const { showPopover, conversation, setShowPopover } = useChatContext();
const { setOption } = useSetIndexOptions();
const { endpoint, conversationId } = conversation ?? {};
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const userProvidesKey = useMemo(
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
const noSettings = useMemo<{ [key: string]: boolean }>(
() => ({
@ -71,14 +76,6 @@ export default function HeaderOptions({
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div className="z-[61] flex w-full items-center justify-center gap-2">
{interfaceConfig?.modelSelect === true && !isAgentsEndpoint(endpoint) && (
<ModelSelect
conversation={conversation}
setOption={setOption}
showAbove={false}
popover={true}
/>
)}
{!noSettings[endpoint] &&
interfaceConfig?.parameters === true &&
paramEndpoint === false && (

View file

@ -28,7 +28,7 @@ export default function Mention({
includeAssistants?: boolean;
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const assistantsMap = useAssistantsMapContext();
const {
options,
presets,
@ -37,11 +37,11 @@ export default function Mention({
modelsConfig,
endpointsConfig,
assistantListMap,
} = useMentions({ assistantMap: assistantMap || {}, includeAssistants });
} = useMentions({ assistantMap: assistantsMap || {}, includeAssistants });
const { onSelectMention } = useSelectMention({
presets,
modelSpecs,
assistantMap,
assistantsMap,
endpointsConfig,
newConversation,
});
@ -65,7 +65,7 @@ export default function Mention({
setSearchValue('');
setOpen(false);
setShowMentionPopover(false);
onSelectMention(mention);
onSelectMention?.(mention);
if (textAreaRef.current) {
removeCharIfLast(textAreaRef.current, commandChar);
@ -158,11 +158,11 @@ export default function Mention({
};
return (
<div className="absolute bottom-14 z-10 w-full space-y-2">
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize(placeholder)}

View file

@ -69,7 +69,9 @@ function PromptsCommand({
label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${
group.name
}: ${
(group.oneliner?.length ?? 0) > 0 ? group.oneliner : group.productionPrompt?.prompt ?? ''
(group.oneliner?.length ?? 0) > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
}`,
icon: <CategoryIcon category={group.category ?? ''} className="h-5 w-5" />,
}));
@ -195,11 +197,11 @@ function PromptsCommand({
variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen}
>
<div className="absolute bottom-14 z-10 w-full space-y-2">
<div className="absolute bottom-28 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}

View file

@ -24,7 +24,7 @@ const SubmitButton = React.memo(
id="send-button"
disabled={props.disabled}
className={cn(
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
data-testid="send-button"
type="submit"
@ -34,7 +34,7 @@ const SubmitButton = React.memo(
</span>
</button>
}
></TooltipAnchor>
/>
);
}),
);

View file

@ -12,7 +12,7 @@ export default function StopButton({ stop, setShowStopButton }) {
<button
type="button"
className={cn(
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
'rounded-full bg-text-primary p-1.5 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
aria-label={localize('com_nav_stop_generating')}
onClick={(e) => {

View file

@ -1,38 +0,0 @@
import { MessageCircleDashed, X } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface TemporaryChatProps {
isTemporaryChat: boolean;
setIsTemporaryChat: (value: boolean) => void;
}
export const TemporaryChat = ({ isTemporaryChat, setIsTemporaryChat }: TemporaryChatProps) => {
const localize = useLocalize();
if (!isTemporaryChat) {
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="flex items-start gap-4 py-2.5 pl-3 pr-1.5 text-sm">
<span className="mt-0 flex h-6 w-6 flex-shrink-0 items-center justify-center">
<div className="icon-md">
<MessageCircleDashed className="icon-md" aria-hidden="true" />
</div>
</span>
<span className="text-token-text-secondary line-clamp-3 flex-1 py-0.5 font-semibold">
{localize('com_ui_temporary_chat')}
</span>
<button
className="text-token-text-secondary flex-shrink-0"
type="button"
aria-label="Close temporary chat"
onClick={() => setIsTemporaryChat(false)}
>
<X className="pr-1" />
</button>
</div>
</div>
);
};

View file

@ -13,7 +13,7 @@ export default function TextareaHeader({
return null;
}
return (
<div className="divide-token-border-light m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<div className="m-1.5 flex flex-col divide-y overflow-hidden rounded-b-lg rounded-t-2xl bg-surface-secondary-alt">
<AddedConvo addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
</div>
);

View file

@ -1,47 +1,50 @@
import { useMemo } from 'react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import { easings } from '@react-spring/web';
import { EModelEndpoint } from 'librechat-data-provider';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import {
useGetAssistantDocsQuery,
useGetEndpointsQuery,
useGetStartupConfig,
} from '~/data-provider';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import { BirthdayIcon, TooltipAnchor, SplitText } from '~/components';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { getIconEndpoint, getEntity, cn } from '~/utils';
import { useLocalize, useSubmitMessage } from '~/hooks';
import { TooltipAnchor } from '~/components/ui';
import { BirthdayIcon } from '~/components/svg';
import ConvoStarter from './ConvoStarter';
import { useLocalize, useAuthContext } from '~/hooks';
import { getIconEndpoint, getEntity } from '~/utils';
export default function Landing({ Header }: { Header?: ReactNode }) {
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
export default function Landing({ centerFormOnLanding }: { centerFormOnLanding: boolean }) {
const { conversation } = useChatContext();
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { data: startupConfig } = useGetStartupConfig();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { user } = useAuthContext();
const localize = useLocalize();
let { endpoint = '' } = conversation ?? {};
const [textHasMultipleLines, setTextHasMultipleLines] = useState(false);
const [lineCount, setLineCount] = useState(1);
const [contentHeight, setContentHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
if (
endpoint === EModelEndpoint.chatGPTBrowser ||
endpoint === EModelEndpoint.azureOpenAI ||
endpoint === EModelEndpoint.gptPlugins
) {
endpoint = EModelEndpoint.openAI;
}
const iconURL = conversation?.iconURL;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const { data: documentsMap = new Map() } = useGetAssistantDocsQuery(endpoint, {
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
});
const endpointType = useMemo(() => {
let ep = conversation?.endpoint ?? '';
if (
[
EModelEndpoint.chatGPTBrowser,
EModelEndpoint.azureOpenAI,
EModelEndpoint.gptPlugins,
].includes(ep as EModelEndpoint)
) {
ep = EModelEndpoint.openAI;
}
return getIconEndpoint({
endpointsConfig,
iconURL: conversation?.iconURL,
endpoint: ep,
});
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
const { entity, isAgent, isAssistant } = getEntity({
endpoint,
endpoint: endpointType,
agentsMap,
assistantMap,
agent_id: conversation?.agent_id,
@ -50,102 +53,144 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
const name = entity?.name ?? '';
const description = entity?.description ?? '';
const avatar = isAgent
? (entity as t.Agent | undefined)?.avatar?.filepath ?? ''
: ((entity as t.Assistant | undefined)?.metadata?.avatar as string | undefined) ?? '';
const conversation_starters = useMemo(() => {
/* The user made updates, use client-side cache, or they exist in an Agent */
if (entity && (entity.conversation_starters?.length ?? 0) > 0) {
return entity.conversation_starters;
}
if (isAgent) {
return entity?.conversation_starters ?? [];
const getGreeting = useCallback(() => {
if (typeof startupConfig?.interface?.customWelcome === 'string') {
const customWelcome = startupConfig.interface.customWelcome;
// Replace {{user.name}} with actual user name if available
if (user?.name && customWelcome.includes('{{user.name}}')) {
return customWelcome.replace(/{{user.name}}/g, user.name);
}
return customWelcome;
}
/* If none in cache, we use the latest assistant docs */
const entityDocs = documentsMap.get(entity?.id ?? '');
return entityDocs?.conversation_starters ?? [];
}, [documentsMap, isAgent, entity]);
const now = new Date();
const hours = now.getHours();
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
const dayOfWeek = now.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const { submitMessage } = useSubmitMessage();
const sendConversationStarter = (text: string) => submitMessage({ text });
// Early morning (midnight to 4:59 AM)
if (hours >= 0 && hours < 5) {
return localize('com_ui_late_night');
}
// Morning (6 AM to 11:59 AM)
else if (hours < 12) {
if (isWeekend) {
return localize('com_ui_weekend_morning');
}
return localize('com_ui_good_morning');
}
// Afternoon (12 PM to 4:59 PM)
else if (hours < 17) {
return localize('com_ui_good_afternoon');
}
// Evening (5 PM to 8:59 PM)
else {
return localize('com_ui_good_evening');
}
}, [localize, startupConfig?.interface?.customWelcome, user?.name]);
const getWelcomeMessage = () => {
const greeting = conversation?.greeting ?? '';
if (greeting) {
return greeting;
const handleLineCountChange = useCallback((count: number) => {
setTextHasMultipleLines(count > 1);
setLineCount(count);
}, []);
useEffect(() => {
if (contentRef.current) {
setContentHeight(contentRef.current.offsetHeight);
}
}, [lineCount, description]);
const getDynamicMargin = useMemo(() => {
let margin = 'mb-0';
if (lineCount > 2 || (description && description.length > 100)) {
margin = 'mb-10';
} else if (lineCount > 1 || (description && description.length > 0)) {
margin = 'mb-6';
} else if (textHasMultipleLines) {
margin = 'mb-4';
}
if (isAssistant) {
return localize('com_nav_welcome_assistant');
if (contentHeight > 200) {
margin = 'mb-16';
} else if (contentHeight > 150) {
margin = 'mb-12';
}
if (isAgent) {
return localize('com_nav_welcome_agent');
}
return typeof startupConfig?.interface?.customWelcome === 'string'
? startupConfig?.interface?.customWelcome
: localize('com_nav_welcome_message');
};
return margin;
}, [lineCount, description, textHasMultipleLines, contentHeight]);
return (
<div className="relative h-full">
<div className="absolute left-0 right-0">{Header != null ? Header : null}</div>
<div className="flex h-full flex-col items-center justify-center">
<div className={cn('relative h-12 w-12', name && avatar ? 'mb-0' : 'mb-3')}>
<ConvoIcon
agentsMap={agentsMap}
assistantMap={assistantMap}
conversation={conversation}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
size={41}
/>
{startupConfig?.showBirthdayIcon === true ? (
<TooltipAnchor
className="absolute bottom-8 right-2.5"
description={localize('com_ui_happy_birthday')}
>
<BirthdayIcon />
</TooltipAnchor>
) : null}
</div>
{name ? (
<div className="flex flex-col items-center gap-0 p-2">
<div className="text-center text-2xl font-medium dark:text-white">{name}</div>
<div className="max-w-md text-center text-sm font-normal text-text-primary ">
{description ||
(typeof startupConfig?.interface?.customWelcome === 'string'
? startupConfig?.interface?.customWelcome
: localize('com_nav_welcome_message'))}
</div>
{/* <div className="mt-1 flex items-center gap-1 text-token-text-tertiary">
<div className="text-sm text-token-text-tertiary">By Daniel Avila</div>
</div> */}
<div
className={`flex h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 ${centerFormOnLanding ? 'max-h-full sm:max-h-0' : 'max-h-full'} ${getDynamicMargin}`}
>
<div ref={contentRef} className="flex flex-col items-center gap-0 p-2">
<div
className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-4`}
>
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
<ConvoIcon
agentsMap={agentsMap}
assistantMap={assistantMap}
conversation={conversation}
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
size={41}
/>
{startupConfig?.showBirthdayIcon && (
<TooltipAnchor
className="absolute bottom-[27px] right-2"
description={localize('com_ui_happy_birthday')}
>
<BirthdayIcon />
</TooltipAnchor>
)}
</div>
) : (
<h2 className="mb-5 max-w-[75vh] px-12 text-center text-lg font-medium dark:text-white md:px-0 md:text-2xl">
{getWelcomeMessage()}
</h2>
)}
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
{conversation_starters.length > 0 &&
conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
<ConvoStarter
key={index}
text={text}
onClick={() => sendConversationStarter(text)}
/>
))}
{((isAgent || isAssistant) && name) || name ? (
<div className="flex flex-col items-center gap-0 p-2">
<SplitText
key={`split-text-${name}`}
text={name}
className="text-4xl font-medium text-text-primary"
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
easing={easings.easeOutCubic}
threshold={0}
rootMargin="0px"
onLineCountChange={handleLineCountChange}
/>
</div>
) : (
<SplitText
key={`split-text-${getGreeting()}${user?.name ? '-user' : ''}`}
text={
typeof startupConfig?.interface?.customWelcome === 'string'
? getGreeting()
: getGreeting() + (user?.name ? ', ' + user.name : '')
}
className="text-2xl font-medium text-text-primary sm:text-4xl"
delay={50}
textAlign="center"
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
easing={easings.easeOutCubic}
threshold={0}
rootMargin="0px"
onLineCountChange={handleLineCountChange}
/>
)}
</div>
{(isAgent || isAssistant) && description && (
<div className="animate-fadeIn mt-2 max-w-md text-center text-sm font-normal text-text-primary">
{description}
</div>
)}
</div>
</div>
);

View file

@ -170,7 +170,7 @@ const BookmarkMenu: FC = () => {
id="bookmark-menu-button"
aria-label={localize('com_ui_bookmarks_add')}
className={cn(
'mt-text-sm flex size-10 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
isMenuOpen ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"

View file

@ -0,0 +1,247 @@
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-lg border-none bg-transparent px-2 text-base',
'sm:h-8 sm:text-sm',
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white',
)}
/>
</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;
}}
/>
);
},
);

View 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;

View file

@ -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,
};

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,107 @@
import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager';
import { useLocalize } from '~/hooks';
function ModelSelectorContent() {
const localize = useLocalize();
const {
// LibreChat
modelSpecs,
mappedEndpoints,
endpointsConfig,
// State
searchValue,
searchResults,
selectedValues,
// Functions
setSearchValue,
setSelectedValues,
// Dialog
keyDialogOpen,
onOpenChange,
keyDialogEndpoint,
} = useModelSelectorContext();
const selectedIcon = useMemo(
() =>
getSelectedIcon({
mappedEndpoints: mappedEndpoints ?? [],
selectedValues,
modelSpecs,
endpointsConfig,
}),
[mappedEndpoints, selectedValues, modelSpecs, endpointsConfig],
);
const selectedDisplayValue = useMemo(
() =>
getDisplayValue({
localize,
modelSpecs,
selectedValues,
mappedEndpoints,
}),
[localize, modelSpecs, selectedValues, mappedEndpoints],
);
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_ui_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({ startupConfig }: ModelSelectorProps) {
return (
<ModelSelectorProvider startupConfig={startupConfig}>
<ModelSelectorContent />
</ModelSelectorProvider>
);
}

View file

@ -0,0 +1,188 @@
import debounce from 'lodash/debounce';
import React, { createContext, useContext, useState, useMemo } from 'react';
import { 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 } 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
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;
startupConfig: t.TStartupConfig | undefined;
}
export function ModelSelectorProvider({ children, startupConfig }: ModelSelectorProviderProps) {
const agentsMap = useAgentsMapContext();
const assistantsMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { conversation, newConversation } = useChatContext();
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
const { mappedEndpoints, endpointRequiresUserKey } = useEndpoints({
agentsMap,
assistantsMap,
startupConfig,
endpointsConfig,
});
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 setDebouncedSearchValue = useMemo(
() =>
debounce((value: string) => {
setSearchValueState(value);
}, 200),
[],
);
const setEndpointSearchValue = (endpoint: string, value: string) => {
setEndpointSearchValues((prev) => ({
...prev,
[endpoint]: value,
}));
};
const handleSelectSpec = (spec: t.TModelSpec) => {
let model = spec.preset.model ?? null;
onSelectSpec?.(spec);
if (isAgentsEndpoint(spec.preset.endpoint)) {
model = spec.preset.agent_id ?? '';
} else if (isAssistantsEndpoint(spec.preset.endpoint)) {
model = spec.preset.assistant_id ?? '';
}
setSelectedValues({
endpoint: spec.preset.endpoint,
model,
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,
model: agentsMap?.[model]?.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,
modelSpec: '',
});
};
const value = {
// State
searchValue,
searchResults,
selectedValues,
endpointSearchValues,
// LibreChat
agentsMap,
modelSpecs,
assistantsMap,
mappedEndpoints,
endpointsConfig,
// Functions
handleSelectSpec,
handleSelectModel,
setSelectedValues,
handleSelectEndpoint,
setEndpointSearchValue,
endpointRequiresUserKey,
setSearchValue: setDebouncedSearchValue,
// Dialog
...keyProps,
};
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
}

View file

@ -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);

View file

@ -0,0 +1,190 @@
import { useMemo } from 'react';
import { SettingsIcon } from 'lucide-react';
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 { TooltipAnchor, Spinner } from '~/components';
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>
{/* TODO: remove this after deprecation */}
{endpoint.value === 'gptPlugins' && (
<TooltipAnchor
description={localize('com_endpoint_deprecated_info')}
aria-label={localize('com_endpoint_deprecated_info_a11y')}
render={
<span className="ml-2 rounded bg-amber-600/70 px-2 py-0.5 text-xs font-semibold text-white">
{localize('com_endpoint_deprecated')}
</span>
}
/>
)}
</div>
);
if (endpoint.hasModels) {
const filteredModels = searchValue
? filterModels(
endpoint,
(endpoint.models || []).map((model) => model.name),
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>
}
>
{isAssistantsEndpoint(endpoint.value) && 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`} />
));
}

View file

@ -0,0 +1,95 @@
import React from 'react';
import { EarthIcon } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } 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 isGlobal = false;
let modelName = modelId;
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
// Use custom names if available
if (endpoint && modelId && isAgentsEndpoint(endpoint.value) && endpoint.agentNames?.[modelId]) {
modelName = endpoint.agentNames[modelId];
const modelInfo = endpoint?.models?.find((m) => m.name === modelId);
isGlobal = modelInfo?.isGlobal ?? false;
} else if (
endpoint &&
modelId &&
isAssistantsEndpoint(endpoint.value) &&
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>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
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>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
{isSelected && (
<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 renderEndpointModels(
endpoint: Endpoint | null,
models: Array<{ name: string; isGlobal?: boolean }>,
selectedModel: string | null,
filteredModels?: string[],
) {
const modelsToRender = filteredModels || models.map((model) => model.name);
return modelsToRender.map(
(modelId) =>
endpoint && (
<EndpointModelItem
key={modelId}
modelId={modelId}
endpoint={endpoint}
isSelected={selectedModel === modelId}
/>
),
);
}

View file

@ -0,0 +1,73 @@
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 items-center justify-between rounded-lg px-2 text-sm',
)}
>
<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="flex-shrink-0 self-center">
<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} />
));
}

View file

@ -0,0 +1,256 @@
import React, { Fragment } from 'react';
import { EarthIcon } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } 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,
} = 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((model) => {
let modelName = model.name;
if (
isAgentsEndpoint(endpoint.value) &&
endpoint.agentNames &&
endpoint.agentNames[model.name]
) {
modelName = endpoint.agentNames[model.name];
} else if (
isAssistantsEndpoint(endpoint.value) &&
endpoint.assistantNames &&
endpoint.assistantNames[model.name]
) {
modelName = endpoint.assistantNames[model.name];
}
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((model) => {
const modelId = model.name;
let isGlobal = false;
let modelName = modelId;
if (
isAgentsEndpoint(endpoint.value) &&
endpoint.agentNames &&
endpoint.agentNames[modelId]
) {
modelName = endpoint.agentNames[modelId];
const modelInfo = endpoint?.models?.find((m) => m.name === modelId);
isGlobal = modelInfo?.isGlobal ?? false;
} else if (
isAssistantsEndpoint(endpoint.value) &&
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>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
{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="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}
/>
);
}

View file

@ -2,27 +2,37 @@ 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 { icons } from '~/components/Chat/Menus/Endpoints/Icons';
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: (props: IconMapProps) => React.JSX.Element;
let Icon: IconType;
if (!iconURL.includes('http')) {
Icon = icons[iconKey] ?? icons.unknown;
Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType;
} else if (iconURL) {
return <URLIcon iconURL={iconURL} altName={currentSpec.name} />;
return (
<URLIcon
iconURL={iconURL}
altName={currentSpec.name}
containerStyle={{ width: 20, height: 20 }}
className="icon-md shrink-0 overflow-hidden rounded-full"
endpoint={endpoint || undefined}
/>
);
} else {
Icon = icons[endpoint ?? ''] ?? icons.unknown;
Icon = (icons[endpoint ?? ''] ?? icons[iconKey] ?? icons.unknown) as IconType;
}
return (
@ -31,7 +41,7 @@ const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) =>
endpoint={endpoint}
context="menu-item"
iconURL={endpointIconURL}
className="icon-lg mr-1 shrink-0 text-text-primary"
className="icon-md shrink-0 text-text-primary"
/>
);
};

View file

@ -0,0 +1,4 @@
export * from './ModelSpecItem';
export * from './EndpointModelItem';
export * from './EndpointItem';
export * from './SearchResults';

View file

@ -0,0 +1,212 @@
import React from 'react';
import { Bot } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TModelSpec,
TAgentsMap,
TAssistantsMap,
TEndpointsConfig,
} from 'librechat-data-provider';
import type { useLocalize } from '~/hooks';
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?: Array<{ name: string; isGlobal?: boolean }>;
},
>(
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.name.toLowerCase().includes(searchTermLower)) {
return true;
}
if (isAgentsEndpoint(item.value) && agentsMap && modelId.name in agentsMap) {
const agentName = agentsMap[modelId.name]?.name;
return typeof agentName === 'string' && agentName.toLowerCase().includes(searchTermLower);
}
if (isAssistantsEndpoint(item.value) && assistantsMap) {
const endpoint = item.value ?? '';
const assistant = assistantsMap[endpoint][modelId.name];
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 (isAgentsEndpoint(endpoint.value) && agentsMap && agentsMap[modelId]) {
modelName = agentsMap[modelId].name || modelId;
} else if (
isAssistantsEndpoint(endpoint.value) &&
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;
}
export const getDisplayValue = ({
localize,
mappedEndpoints,
selectedValues,
modelSpecs,
}: {
localize: ReturnType<typeof useLocalize>;
selectedValues: SelectedValues;
mappedEndpoints: Endpoint[];
modelSpecs: TModelSpec[];
}) => {
if (selectedValues.modelSpec) {
const spec = modelSpecs.find((s) => s.name === selectedValues.modelSpec);
return spec?.label || spec?.name || localize('com_ui_select_model');
}
if (selectedValues.model && selectedValues.endpoint) {
const endpoint = mappedEndpoints.find((e) => e.value === selectedValues.endpoint);
if (!endpoint) {
return localize('com_ui_select_model');
}
if (
isAgentsEndpoint(endpoint.value) &&
endpoint.agentNames &&
endpoint.agentNames[selectedValues.model]
) {
return endpoint.agentNames[selectedValues.model];
}
if (
isAssistantsEndpoint(endpoint.value) &&
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_ui_select_model');
}
return localize('com_ui_select_model');
};

View file

@ -1,105 +0,0 @@
import { useCallback, useRef } from 'react';
import { alternateName } from 'librechat-data-provider';
import { Content, Portal, Root } from '@radix-ui/react-popover';
import type { FC, KeyboardEvent } from 'react';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetEndpointsQuery } from '~/data-provider';
import { mapEndpoints, getEntity } from '~/utils';
import EndpointItems from './Endpoints/MenuItems';
import useLocalize from '~/hooks/useLocalize';
import TitleButton from './UI/TitleButton';
const EndpointsMenu: FC = () => {
const { data: endpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
const localize = useLocalize();
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { conversation } = useChatContext();
const { endpoint = '' } = conversation ?? {};
const menuRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
if (!menuItems) {
return;
}
if (!menuItems.length) {
return;
}
const currentIndex = Array.from(menuItems).findIndex((item) => item === document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (currentIndex < menuItems.length - 1) {
(menuItems[currentIndex + 1] as HTMLElement).focus();
} else {
(menuItems[0] as HTMLElement).focus();
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
(menuItems[currentIndex - 1] as HTMLElement).focus();
} else {
(menuItems[menuItems.length - 1] as HTMLElement).focus();
}
break;
}
}, []);
if (!endpoint) {
console.warn('No endpoint selected');
return null;
}
const { entity } = getEntity({
endpoint,
agentsMap,
assistantMap,
agent_id: conversation?.agent_id,
assistant_id: conversation?.assistant_id,
});
const primaryText = entity
? entity.name
: (alternateName[endpoint] as string | undefined) ?? endpoint;
return (
<Root>
<TitleButton primaryText={primaryText + ' '} />
<Portal>
<div
style={{
position: 'fixed',
left: '0px',
top: '0px',
transform: 'translate3d(268px, 50px, 0px)',
minWidth: 'max-content',
zIndex: 'auto',
}}
>
<Content
side="bottom"
align="start"
role="listbox"
id="llm-endpoint-menu"
ref={menuRef}
onKeyDown={handleKeyDown}
aria-label={localize('com_ui_endpoints_available')}
className="mt-2 max-h-[65vh] min-w-[340px] overflow-y-auto rounded-lg border border-border-light bg-header-primary text-text-primary shadow-lg lg:max-h-[75vh]"
>
<EndpointItems endpoints={endpoints} selected={endpoint} />
</Content>
</div>
</Portal>
</Root>
);
};
export default EndpointsMenu;

View file

@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { useMediaQuery, useLocalize } from '~/hooks';
import { NewChatIcon } from '~/components/svg';
import { Button, NewChatIcon } from '~/components';
import { useChatContext } from '~/Providers';
export default function HeaderNewChat() {
@ -10,15 +10,18 @@ export default function HeaderNewChat() {
const { conversation, newConversation } = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const localize = useLocalize();
if (isSmallScreen) {
return null;
}
return (
<button
<Button
size="icon"
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
type="button"
className="btn btn-neutral btn-small border-token-border-medium focus:border-black-500 dark:focus:border-white-500 relative ml-2 flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg border md:flex"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
onClick={() => {
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
@ -27,9 +30,7 @@ export default function HeaderNewChat() {
newConversation();
}}
>
<div className="flex w-full items-center justify-center gap-2">
<NewChatIcon />
</div>
</button>
<NewChatIcon />
</Button>
);
}

View file

@ -1,66 +0,0 @@
import { useState } from 'react';
import { Trigger } from '@radix-ui/react-popover';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
export default function MenuButton({
selected,
className = '',
textClassName = '',
primaryText = '',
secondaryText = '',
endpointsConfig,
}: {
selected?: TModelSpec;
className?: string;
textClassName?: string;
primaryText?: string;
secondaryText?: string;
endpointsConfig: TEndpointsConfig;
}) {
const localize = useLocalize();
const [isExpanded, setIsExpanded] = useState(false);
return (
<Trigger asChild>
<button
className={cn(
'group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
className,
)}
type="button"
aria-label={localize('com_ui_llm_menu')}
role="combobox"
aria-haspopup="listbox"
aria-expanded={isExpanded}
aria-controls="llm-menu"
onClick={() => setIsExpanded(!isExpanded)}
>
{selected && selected.showIconInHeader === true && (
<SpecIcon currentSpec={selected} endpointsConfig={endpointsConfig} />
)}
<div className={textClassName}>
{!selected ? localize('com_ui_none_selected') : primaryText}{' '}
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
</div>
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
className="text-token-text-tertiary"
>
<path
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</Trigger>
);
}

View file

@ -1,153 +0,0 @@
import { useState, useMemo } from 'react';
import { Settings } from 'lucide-react';
import type { FC } from 'react';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { useLocalize, useUserKey } from '~/hooks';
import { cn, getEndpointField } from '~/utils';
import SpecIcon from './SpecIcon';
type MenuItemProps = {
title: string;
spec: TModelSpec;
selected: boolean;
description?: string;
userProvidesKey: boolean;
endpointsConfig: TEndpointsConfig;
onClick?: () => void;
// iconPath: string;
// hoverContent?: string;
};
const MenuItem: FC<MenuItemProps> = ({
title,
spec,
selected,
description,
userProvidesKey,
endpointsConfig,
onClick,
...rest
}) => {
const { endpoint } = spec.preset;
const [isDialogOpen, setDialogOpen] = useState(false);
const { getExpiry } = useUserKey(endpoint ?? '');
const localize = useLocalize();
const expiryTime = getExpiry() ?? '';
const clickHandler = () => {
if (expiryTime) {
setDialogOpen(true);
}
if (onClick) {
onClick();
}
};
const endpointType = useMemo(
() => spec.preset.endpointType ?? getEndpointField(endpointsConfig, endpoint, 'type'),
[spec, endpointsConfig, endpoint],
);
const { showIconInMenu = true } = spec;
return (
<>
<div
id={selected ? 'selected-llm' : undefined}
role="option"
aria-selected={selected}
className="group m-1.5 flex cursor-pointer gap-2 rounded px-1 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
tabIndex={0}
{...rest}
onClick={clickHandler}
aria-label={title}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
clickHandler();
}
}}
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className="flex items-center gap-2">
{showIconInMenu && <SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />}
<div>
{title}
<div className="text-text-secondary">{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
? 'w-full rounded-lg p-2 hover:bg-gray-200 dark:hover:bg-gray-900'
: '',
)}
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"
// 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>
)}
</div>
</div>
</div>
{userProvidesKey && (
<SetKeyDialog
open={isDialogOpen}
onOpenChange={setDialogOpen}
endpoint={endpoint ?? ''}
endpointType={endpointType}
/>
)}
</>
);
};
export default MenuItem;

View file

@ -1,44 +0,0 @@
import type { FC } from 'react';
import { Close } from '@radix-ui/react-popover';
import { AuthType } from 'librechat-data-provider';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import MenuSeparator from '~/components/Chat/Menus/UI/MenuSeparator';
import ModelSpec from './ModelSpec';
const ModelSpecs: FC<{
specs?: Array<TModelSpec | undefined>;
selected?: TModelSpec;
setSelected?: (spec: TModelSpec) => void;
endpointsConfig: TEndpointsConfig;
}> = ({ specs = [], selected, setSelected = () => ({}), endpointsConfig }) => {
return (
<>
{specs.length &&
specs.map((spec, i) => {
if (!spec) {
return null;
}
return (
<Close asChild key={`spec-${spec.name}`}>
<div key={`spec-${spec.name}`}>
<ModelSpec
spec={spec}
title={spec.label}
key={`spec-item-${spec.name}`}
description={spec.description}
onClick={() => setSelected(spec)}
data-testid={`spec-item-${spec.name}`}
selected={selected?.name === spec.name}
userProvidesKey={spec.authType === AuthType.USER_PROVIDED}
endpointsConfig={endpointsConfig}
/>
{i !== specs.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
</>
);
};
export default ModelSpecs;

View file

@ -1,168 +0,0 @@
import { useRecoilValue } from 'recoil';
import { useMemo, useCallback, useRef } from 'react';
import { Content, Portal, Root } from '@radix-ui/react-popover';
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec, TConversation, TEndpointsConfig } from 'librechat-data-provider';
import type { KeyboardEvent } from 'react';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { useDefaultConvo, useNewConvo, useLocalize } from '~/hooks';
import { getConvoSwitchLogic, getModelSpecIconURL } from '~/utils';
import { useGetEndpointsQuery } from '~/data-provider';
import MenuButton from './MenuButton';
import ModelSpecs from './ModelSpecs';
import store from '~/store';
export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs?: TModelSpec[] }) {
const { conversation } = useChatContext();
const { newConversation } = useNewConvo();
const localize = useLocalize();
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const modularChat = useRecoilValue(store.modularChat);
const getDefaultConversation = useDefaultConvo();
const assistantMap = useAssistantsMapContext();
const onSelectSpec = (spec: TModelSpec) => {
const { preset } = spec;
preset.iconURL = getModelSpecIconURL(spec);
preset.spec = spec.name;
const { endpoint } = preset;
const newEndpoint = endpoint ?? '';
if (!newEndpoint) {
return;
}
const {
template,
shouldSwitch,
isNewModular,
newEndpointType,
isCurrentModular,
isExistingConversation,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
if (newEndpointType) {
preset.endpointType = newEndpointType;
}
if (isAssistantsEndpoint(newEndpoint) && preset.assistant_id != null && !(preset.model ?? '')) {
preset.model = assistantMap?.[newEndpoint]?.[preset.assistant_id]?.model;
}
const isModular = isCurrentModular && isNewModular && shouldSwitch;
if (isExistingConversation && isModular) {
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,
keepAddedConvos: true,
});
return;
}
newConversation({
template: { ...(template as Partial<TConversation>) },
preset,
keepAddedConvos: isModular,
});
};
const selected = useMemo(() => {
const spec = modelSpecs?.find((spec) => spec.name === conversation?.spec);
if (!spec) {
return undefined;
}
return spec;
}, [modelSpecs, conversation?.spec]);
const menuRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
const menuItems = menuRef.current?.querySelectorAll('[role="option"]');
if (!menuItems) {
return;
}
if (!menuItems.length) {
return;
}
const currentIndex = Array.from(menuItems).findIndex((item) => item === document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (currentIndex < menuItems.length - 1) {
(menuItems[currentIndex + 1] as HTMLElement).focus();
} else {
(menuItems[0] as HTMLElement).focus();
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
(menuItems[currentIndex - 1] as HTMLElement).focus();
} else {
(menuItems[menuItems.length - 1] as HTMLElement).focus();
}
break;
}
}, []);
return (
<Root>
<MenuButton
selected={selected}
className="min-h-11"
textClassName="block items-center justify-start text-xs md:text-base whitespace-nowrap max-w-64 overflow-hidden shrink-0 text-ellipsis"
primaryText={selected?.label ?? ''}
endpointsConfig={endpointsConfig}
/>
<Portal>
{modelSpecs && modelSpecs.length && (
<div
style={{
position: 'fixed',
left: '0px',
top: '0px',
transform: 'translate3d(268px, 50px, 0px)',
minWidth: 'max-content',
zIndex: 'auto',
}}
>
<Content
side="bottom"
align="start"
id="llm-menu"
role="listbox"
ref={menuRef}
onKeyDown={handleKeyDown}
aria-label={localize('com_ui_llms_available')}
className="models-scrollbar mt-2 max-h-[65vh] min-w-[340px] max-w-xs overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-[75vh]"
>
<ModelSpecs
specs={modelSpecs}
selected={selected}
setSelected={onSelectSpec}
endpointsConfig={endpointsConfig}
/>
</Content>
</div>
)}
</Portal>
</Root>
);
}

View file

@ -10,7 +10,7 @@ import { Dialog, DialogTrigger, Label } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { useGetEndpointsQuery } from '~/data-provider';
import { MenuSeparator, MenuItem } from '../UI';
import { icons } from '../Endpoints/Icons';
import { icons } from '~/hooks/Endpoint/Icons';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -39,7 +39,7 @@ const PresetItems: FC<{
<>
<div
role="menuitem"
className="pointer-none group m-1.5 flex h-8 min-w-[170px] gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 md:min-w-[240px]"
className="pointer-none group m-1.5 flex h-8 min-w-[170px] gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 md:min-w-[240px]"
tabIndex={-1}
>
<div className="flex h-full grow items-center justify-end gap-2">

View file

@ -30,7 +30,7 @@ const PresetsMenu: FC = () => {
tabIndex={0}
role="button"
data-testid="presets-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<BookCopy size={16} aria-label="Preset Icon" />
</TooltipAnchor>

View file

@ -1,4 +1,2 @@
export { default as PresetsMenu } from './PresetsMenu';
export { default as EndpointsMenu } from './EndpointsMenu';
export { default as HeaderNewChat } from './HeaderNewChat';
export { default as ModelSpecsMenu } from './Models/ModelSpecsMenu';

View file

@ -38,7 +38,13 @@ const Part = memo(
if (part.type === ContentTypes.ERROR) {
return (
<ErrorMessage
text={part[ContentTypes.ERROR] ?? part[ContentTypes.TEXT]?.value}
text={
part[ContentTypes.ERROR] ??
(typeof part[ContentTypes.TEXT] === 'string'
? part[ContentTypes.TEXT]
: part.text?.value) ??
''
}
className="my-2"
/>
);

View file

@ -1,13 +1,16 @@
import React, { useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import MessageIcon from '~/components/Share/MessageIcon';
import { useAgentsMapContext } from '~/Providers';
import Icon from '~/components/Endpoints/Icon';
import { useLocalize } from '~/hooks';
interface AgentUpdateProps {
currentAgentId: string;
}
const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
const localize = useLocalize();
const agentsMap = useAgentsMapContext() || {};
const currentAgent = useMemo(() => agentsMap[currentAgentId], [agentsMap, currentAgentId]);
if (!currentAgentId) {
@ -23,14 +26,19 @@ const AgentUpdate: React.FC<AgentUpdateProps> = ({ currentAgentId }) => {
</div>
<div className="my-4 flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon
endpoint={EModelEndpoint.agents}
agentName={currentAgent?.name ?? ''}
iconURL={currentAgent?.avatar?.filepath}
isCreatedByUser={false}
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={currentAgent}
/>
</div>
<div className="font-medium text-text-primary">{currentAgent?.name}</div>
<div className="text-base font-medium text-text-primary">
{currentAgent?.name || localize('com_ui_agent')}
</div>
</div>
</div>
);

View file

@ -6,7 +6,7 @@ import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useMessageHelpers, useLocalize } from '~/hooks';
import ContentParts from './Content/ContentParts';
import SiblingSwitch from './SiblingSwitch';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
@ -33,8 +33,11 @@ export default function Message(props: TMessageProps) {
copyToClipboard,
regenerateMessage,
} = useMessageHelpers(props);
const fontSize = useRecoilValue(store.fontSize);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const { children, messageId = null, isCreatedByUser } = message ?? {};
const name = useMemo(() => {
let result = '';
if (isCreatedByUser === true) {
@ -67,71 +70,86 @@ export default function Message(props: TMessageProps) {
message?.isCreatedByUser,
],
);
if (!message) {
return null;
}
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu',
chat: maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-[47rem] xl:max-w-[55rem]',
};
return (
<>
<div
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
className="w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
onWheel={handleScroll}
onTouchMove={handleScroll}
>
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<div
id={messageId}
aria-label={`message-${message.depth}-${messageId}`}
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
>
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<div
className={cn(
'relative flex w-full flex-col',
isCreatedByUser === true ? '' : 'agent-turn',
'relative flex w-11/12 flex-col',
isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<div className={cn('select-none font-semibold', fontSize)}>{name}</div>
<div className="flex-col gap-1 md:gap-3">
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
{name}
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts
edit={edit}
isLast={isLast}
isSubmitting={isSubmitting}
enterEdit={enterEdit}
siblingIdx={siblingIdx}
messageId={message.messageId}
isSubmitting={isSubmitting}
setSiblingIdx={setSiblingIdx}
attachments={message.attachments}
isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId}
content={message.content as Array<TMessageContentParts | undefined>}
/>
</div>
{isLast && isSubmitting ? (
<div className="mt-1 h-[27px] bg-transparent" />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={message}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
{isLast && isSubmitting ? (
<div className="mt-1 h-[27px] bg-transparent" />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={message}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={() => regenerateMessage()}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
</div>

View file

@ -1,7 +1,6 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { CSSTransition } from 'react-transition-group';
import type { ReactNode } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
@ -11,13 +10,12 @@ import store from '~/store';
export default function MessagesView({
messagesTree: _messagesTree,
Header,
}: {
messagesTree?: TMessage[] | null;
Header?: ReactNode;
}) {
const localize = useLocalize();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const { screenshotTargetRef } = useScreenshot();
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
@ -34,62 +32,64 @@ export default function MessagesView({
const { conversationId } = conversation ?? {};
return (
<div className="flex-1 overflow-hidden overflow-y-auto">
<div className="relative h-full">
<div
className="scrollbar-gutter-stable"
onScroll={debouncedHandleScroll}
ref={scrollableRef}
style={{
height: '100%',
overflowY: 'auto',
width: '100%',
}}
>
<div className="flex flex-col pb-9 dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(
'flex w-full items-center justify-center p-3 text-text-secondary',
fontSize,
)}
>
{localize('com_ui_nothing_found')}
</div>
) : (
<>
{Header != null && Header}
<div ref={screenshotTargetRef}>
<MultiMessage
key={conversationId} // avoid internal state mixture
messagesTree={_messagesTree}
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>
<>
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative h-full">
<div
className="scrollbar-gutter-stable"
onScroll={debouncedHandleScroll}
ref={scrollableRef}
style={{
height: '100%',
overflowY: 'auto',
width: '100%',
}}
>
<div className="flex flex-col pb-9 dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div
className={cn(
'flex w-full items-center justify-center p-3 text-text-secondary',
fontSize,
)}
>
{localize('com_ui_nothing_found')}
</div>
</>
)}
<div
id="messages-end"
className="group h-0 w-full flex-shrink-0"
ref={messagesEndRef}
/>
) : (
<>
<div ref={screenshotTargetRef}>
<MultiMessage
key={conversationId}
messagesTree={_messagesTree}
messageId={conversationId ?? null}
setCurrentEditId={setCurrentEditId}
currentEditId={currentEditId ?? null}
/>
</div>
</>
)}
<div
id="messages-end"
className="group h-0 w-full flex-shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<CSSTransition
in={showScrollButton && scrollButtonPreference}
timeout={{
enter: 550,
exit: 700,
}}
classNames="scroll-animation"
unmountOnExit={true}
appear={true}
>
<ScrollToBottom scrollHandler={handleSmoothToRef} />
</CSSTransition>
</div>
<CSSTransition
in={showScrollButton}
timeout={400}
classNames="scroll-down"
unmountOnExit={false}
// appear
>
{() =>
showScrollButton &&
scrollButtonPreference && <ScrollToBottom scrollHandler={handleSmoothToRef} />
}
</CSSTransition>
</div>
</div>
</>
);
}

View file

@ -3,11 +3,11 @@ import { useEffect, useCallback } from 'react';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import MessageContent from '~/components/Messages/MessageContent';
// eslint-disable-next-line import/no-cycle
import MessageParts from './MessageParts';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
import store from '~/store';
@ -30,7 +30,6 @@ export default function MultiMessage({
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
setSiblingIdx(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesTree?.length]);
useEffect(() => {

View file

@ -26,20 +26,21 @@ type MessageRenderProps = {
const MessageRender = memo(
({
isCard,
message: msg,
isCard = false,
siblingIdx,
siblingCount,
message: msg,
setSiblingIdx,
currentEditId,
isMultiMessage,
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily,
isSubmittingFamily = false,
}: MessageRenderProps) => {
const {
ask,
edit,
index,
agent,
assistant,
enterEdit,
conversation,
@ -56,28 +57,31 @@ const MessageRender = memo(
isMultiMessage,
setCurrentEditId,
});
const fontSize = useRecoilValue(store.fontSize);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
const { isCreatedByUser, error, unfinished } = msg ?? {};
const hasNoChildren = !(msg?.children?.length ?? 0);
const isLast = useMemo(
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[hasNoChildren, msg?.depth, latestMessage?.depth],
);
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
const showCardRender = isLast && !isSubmittingFamily && isCard;
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
const iconData: TMessageIcon = useMemo(
() => ({
endpoint: msg?.endpoint ?? conversation?.endpoint,
model: msg?.model ?? conversation?.model,
iconURL: msg?.iconURL ?? conversation?.iconURL,
iconURL: msg?.iconURL,
modelLabel: messageLabel,
isCreatedByUser: msg?.isCreatedByUser,
}),
[
messageLabel,
conversation?.endpoint,
conversation?.iconURL,
conversation?.model,
msg?.model,
msg?.iconURL,
@ -86,49 +90,47 @@ const MessageRender = memo(
],
);
const clickHandler = useMemo(
() =>
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
logger.dir(msg);
setLatestMessage(msg!);
}
: undefined,
[showCardRender, isLatestMessage, msg, setLatestMessage],
);
if (!msg) {
return null;
}
const isLatestMessage = msg.messageId === latestMessage?.messageId;
const showCardRender = isLast && !(isSubmittingFamily === true) && isCard === true;
const isLatestCard = isCard === true && !(isSubmittingFamily === true) && isLatestMessage;
const clickHandler =
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg.messageId} as latest message`);
logger.dir(msg);
setLatestMessage(msg);
}
: undefined;
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
card: 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4',
chat: maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-[47rem] xl:max-w-[55rem]',
};
// Style classes
const baseClasses =
'final-completion group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu';
let layoutClasses = '';
if (isCard ?? false) {
layoutClasses =
'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4';
} else if (maximizeChatSpace) {
layoutClasses = 'md:max-w-full md:px-5';
} else {
layoutClasses = 'md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5';
}
const latestCardClasses = isLatestCard ? 'bg-surface-secondary' : '';
const showRenderClasses = showCardRender ? 'cursor-pointer transition-colors duration-300' : '';
const conditionalClasses = {
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
cardRender: showCardRender ? 'cursor-pointer transition-colors duration-300' : '',
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
};
return (
<div
id={msg.messageId}
aria-label={`message-${msg.depth}-${msg.messageId}`}
className={cn(
baseClasses,
layoutClasses,
latestCardClasses,
showRenderClasses,
'message-render focus:outline-none focus:ring-2 focus:ring-border-xheavy',
baseClasses.common,
isCard ? baseClasses.card : baseClasses.chat,
conditionalClasses.latestCard,
conditionalClasses.cardRender,
conditionalClasses.focus,
'message-render',
)}
onClick={clickHandler}
onKeyDown={(e) => {
@ -139,31 +141,31 @@ const MessageRender = memo(
role={showCardRender ? 'button' : undefined}
tabIndex={showCardRender ? 0 : undefined}
>
{isLatestCard === true && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary"></div>
{isLatestCard && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
)}
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} />
</div>
</div>
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<div
className={cn(
'relative flex w-11/12 flex-col',
msg.isCreatedByUser === true ? '' : 'agent-turn',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<div className="flex-col gap-1 md:gap-3">
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<MessageContext.Provider
value={{
messageId: msg.messageId,
conversationId: conversation?.conversationId,
isExpanded: false,
}}
>
{msg.plugin && <Plugin plugin={msg.plugin} />}
@ -174,40 +176,41 @@ const MessageRender = memo(
text={msg.text || ''}
message={msg}
enterEdit={enterEdit}
error={!!(error ?? false)}
error={!!(msg.error ?? false)}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
unfinished={msg.unfinished ?? false}
isCreatedByUser={msg.isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
/>
</MessageContext.Provider>
</div>
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
);

View file

@ -57,14 +57,6 @@ export default function Presentation({ children }: { children: React.ReactNode }
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
const layout = () => (
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-presentation pt-0">
<div className="flex h-full flex-col" role="presentation">
{children}
</div>
</div>
);
return (
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
<SidePanelGroup

View file

@ -0,0 +1,69 @@
import React from 'react';
import { motion } from 'framer-motion';
import { MessageCircleDashed } from 'lucide-react';
import { useRecoilState, useRecoilCallback } from 'recoil';
import { TooltipAnchor } from '~/components/ui';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export function TemporaryChat() {
const localize = useLocalize();
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const { conversation, isSubmitting } = useChatContext();
const temporaryBadge = {
id: 'temporary',
icon: MessageCircleDashed,
label: 'com_ui_temporary' as const,
atom: store.isTemporary,
isAvailable: true,
};
const handleBadgeToggle = useRecoilCallback(
() => () => {
setIsTemporary(!isTemporary);
},
[isTemporary],
);
if (
(Array.isArray(conversation?.messages) && conversation.messages.length >= 1) ||
isSubmitting
) {
return null;
}
return (
<div className="relative flex flex-wrap items-center gap-2">
<div className="badge-icon h-full">
<TooltipAnchor
description={localize(temporaryBadge.label)}
render={
<motion.button
onClick={handleBadgeToggle}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-lg border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
isTemporary
? 'bg-surface-active shadow-md'
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:scale-95 active:shadow-inner',
)}
transition={{ type: 'tween', duration: 0.1, ease: 'easeOut' }}
>
{temporaryBadge.icon && (
<temporaryBadge.icon
className={cn(
'relative h-5 w-5 md:h-4 md:w-4',
!temporaryBadge.label && 'mx-auto',
)}
/>
)}
</motion.button>
}
/>
</div>
</div>
);
}

View file

@ -1,8 +1,8 @@
import React, { useMemo } from 'react';
import type * as t from 'librechat-data-provider';
import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { icons } from '~/hooks/Endpoint/Icons';
export default function ConvoIcon({
conversation,

View file

@ -1,7 +1,7 @@
import { memo, useMemo } from 'react';
import type { IconMapProps } from '~/common';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import { URLIcon } from '~/components/Endpoints/URLIcon';
import { icons } from '~/hooks/Endpoint/Icons';
interface ConvoIconURLProps {
iconURL?: string;
@ -39,12 +39,7 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
agentName,
context,
}) => {
const Icon: (
props: IconMapProps & {
context?: string;
iconURL?: string;
},
) => React.JSX.Element = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]);
const Icon = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]);
const isURL = useMemo(
() => !!(iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))),
[iconURL],
@ -63,15 +58,17 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
return (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
<Icon
size={41}
context={context}
className="h-2/3 w-2/3"
agentName={agentName}
iconURL={endpointIconURL}
assistantName={assistantName}
avatar={assistantAvatar ?? agentAvatar}
/>
{Icon && (
<Icon
size={41}
context={context}
className="h-2/3 w-2/3"
agentName={agentName}
iconURL={endpointIconURL}
assistantName={assistantName}
avatar={assistantAvatar || agentAvatar}
/>
)}
</div>
);
};

View file

@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, useState } from 'react';
import type { TUser } from 'librechat-data-provider';
import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon';
@ -16,32 +16,50 @@ type UserAvatarProps = {
className?: string;
};
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => (
<div
title={username}
style={{
width: size,
height: size,
}}
className={cn('relative flex items-center justify-center', className ?? '')}
>
{!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '') ? (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
>
<UserIcon />
</div>
) : (
<img className="rounded-full" src={(user?.avatar ?? '') || avatarSrc} alt="avatar" />
)}
</div>
));
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
const renderDefaultAvatar = () => (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
>
<UserIcon />
</div>
);
return (
<div
title={username}
style={{
width: size,
height: size,
}}
className={cn('relative flex items-center justify-center', className ?? '')}
>
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
imageError ? (
renderDefaultAvatar()
) : (
<img
className="rounded-full"
src={(user?.avatar ?? '') || avatarSrc}
alt="avatar"
onError={handleImageError}
/>
)}
</div>
);
});
UserAvatar.displayName = 'UserAvatar';

View file

@ -13,7 +13,7 @@ import {
AzureMinimalIcon,
CustomMinimalIcon,
} from '~/components/svg';
import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
import { IconProps } from '~/common';
import { cn } from '~/utils';
@ -186,7 +186,7 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
let { icon, bg, name } =
endpoint != null && endpoint && endpointIcons[endpoint]
? endpointIcons[endpoint] ?? {}
? (endpointIcons[endpoint] ?? {})
: (endpointIcons.default as EndpointIcon);
if (iconURL && endpointIcons[iconURL]) {

View file

@ -11,7 +11,7 @@ import {
BedrockIcon,
Sparkles,
} from '~/components/svg';
import UnknownIcon from '~/components/Chat/Menus/Endpoints/UnknownIcon';
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
import { IconProps } from '~/common';
import { cn } from '~/utils';

View file

@ -1,21 +1,66 @@
import React, { memo } from 'react';
import React, { memo, useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { icons } from '~/hooks/Endpoint/Icons';
export const URLIcon = memo(
({
iconURL,
altName,
containerStyle = { width: '20', height: '20' },
containerStyle = { width: 20, height: 20 },
imageStyle = { width: '100%', height: '100%' },
className = 'icon-xl mr-1 shrink-0 overflow-hidden rounded-full',
className = 'icon-md mr-1 shrink-0 overflow-hidden rounded-full',
endpoint,
}: {
iconURL: string;
altName?: string | null;
className?: string;
containerStyle?: React.CSSProperties;
imageStyle?: React.CSSProperties;
}) => (
<div className={className} style={containerStyle}>
<img src={iconURL} alt={altName ?? ''} style={imageStyle} className="object-cover" />
</div>
),
endpoint?: string;
}) => {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
const DefaultIcon: React.ElementType =
endpoint && icons[endpoint] ? icons[endpoint]! : icons.unknown!;
if (imageError || !iconURL) {
return (
<div className="relative" style={{ ...containerStyle, margin: '2px' }}>
<div className={className}>
<DefaultIcon endpoint={endpoint} context="menu-item" size={containerStyle.width} />
</div>
{imageError && iconURL && (
<div
className="absolute flex items-center justify-center rounded-full bg-red-500"
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
>
<AlertCircle size={10} className="text-white" />
</div>
)}
</div>
);
}
return (
<div className={className} style={containerStyle}>
<img
src={iconURL}
alt={altName ?? 'Icon'}
style={imageStyle}
className="object-cover"
onError={handleImageError}
loading="lazy"
decoding="async"
width={Number(containerStyle.width) || 20}
height={Number(containerStyle.height) || 20}
/>
</div>
);
},
);
URLIcon.displayName = 'URLIcon';

View file

@ -1,7 +1,6 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { cn, cardStyle } from '~/utils/';
import { TemporaryChat } from './TemporaryChat';
export default function Anthropic({
conversation,
@ -22,7 +21,6 @@ export default function Anthropic({
cardStyle,
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,6 +1,5 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { TemporaryChat } from './TemporaryChat';
import { cn, cardStyle } from '~/utils/';
export default function ChatGPT({
@ -29,7 +28,6 @@ export default function ChatGPT({
cardStyle,
'z-50 flex h-[40px] w-60 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,6 +1,5 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { TemporaryChat } from './TemporaryChat';
import { cn, cardStyle } from '~/utils/';
export default function Google({
@ -22,7 +21,6 @@ export default function Google({
cardStyle,
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,6 +1,5 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { TemporaryChat } from './TemporaryChat';
import { cn, cardStyle } from '~/utils/';
export default function OpenAI({
@ -22,7 +21,6 @@ export default function OpenAI({
cardStyle,
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,65 +0,0 @@
import { useMemo } from 'react';
import { MessageCircleDashed } from 'lucide-react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Constants, getConfigDefaults } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
export const TemporaryChat = () => {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const defaultInterface = getConfigDefaults().interface;
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? '';
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
if (interfaceConfig.temporaryChat === false) {
return null;
}
const isActiveConvo = Boolean(
conversation &&
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search',
);
const onClick = () => {
if (isActiveConvo) {
return;
}
setIsTemporary(!isTemporary);
};
return (
<div className="sticky bottom-0 mt-auto w-full border-none bg-surface-tertiary px-6 py-4">
<div className="flex items-center justify-between">
<div className={cn('flex items-center gap-2', isActiveConvo && 'opacity-40')}>
<MessageCircleDashed className="icon-sm" aria-hidden="true" />
<span className="truncate text-sm text-text-primary">
{localize('com_ui_temporary_chat')}
</span>
</div>
<div className="flex flex-shrink-0 items-center">
<Switch
id="temporary-chat-switch"
checked={isTemporary}
onCheckedChange={onClick}
disabled={isActiveConvo}
className="ml-4"
aria-label="Toggle temporary chat"
data-testid="temporary-chat-switch"
/>
</div>
</div>
</div>
);
};

View file

@ -1,5 +1,5 @@
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
import { ViolationTypes, ErrorTypes } from 'librechat-data-provider';
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
import type { TOpenAIMessage } from 'librechat-data-provider';
import type { LocalizeFunction } from '~/common';
import { formatJSON, extractJson, isJson } from '~/utils/json';
@ -53,6 +53,11 @@ const errorMessages = {
const { info } = json;
return localize('com_error_input_length', { 0: info });
},
[ErrorTypes.INVALID_AGENT_PROVIDER]: (json: TGenericError, localize: LocalizeFunction) => {
const { info } = json;
const provider = (alternateName[info] as string | undefined) ?? info;
return localize('com_error_invalid_agent_provider', { 0: provider });
},
[ErrorTypes.GOOGLE_ERROR]: (json: TGenericError) => {
const { info } = json;
return info;

View file

@ -24,18 +24,17 @@ type ContentRenderProps = {
const ContentRender = memo(
({
isCard,
message: msg,
isCard = false,
siblingIdx,
siblingCount,
message: msg,
setSiblingIdx,
currentEditId,
isMultiMessage,
isMultiMessage = false,
setCurrentEditId,
isSubmittingFamily,
isSubmittingFamily = false,
}: ContentRenderProps) => {
const {
// ask,
edit,
index,
agent,
@ -58,26 +57,28 @@ const ContentRender = memo(
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
// const { isCreatedByUser, error, unfinished } = msg ?? {};
const isLast = useMemo(
() =>
!(msg?.children?.length ?? 0) && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
[msg?.children, msg?.depth, latestMessage?.depth],
);
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
const showCardRender = isLast && !isSubmittingFamily && isCard;
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
const iconData: TMessageIcon = useMemo(
() => ({
endpoint: msg?.endpoint ?? conversation?.endpoint,
model: msg?.model ?? conversation?.model,
iconURL: msg?.iconURL ?? conversation?.iconURL,
iconURL: msg?.iconURL,
modelLabel: messageLabel,
isCreatedByUser: msg?.isCreatedByUser,
}),
[
messageLabel,
conversation?.endpoint,
conversation?.iconURL,
conversation?.model,
msg?.model,
msg?.iconURL,
@ -86,31 +87,29 @@ const ContentRender = memo(
],
);
const clickHandler = useMemo(
() =>
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
logger.dir(msg);
setLatestMessage(msg!);
}
: undefined,
[showCardRender, isLatestMessage, msg, setLatestMessage],
);
if (!msg) {
return null;
}
const isLatestMessage = msg.messageId === latestMessage?.messageId;
const showCardRender = isLast && !(isSubmittingFamily === true) && isCard === true;
const isLatestCard = isCard === true && !(isSubmittingFamily === true) && isLatestMessage;
const clickHandler =
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg.messageId} as latest message`);
logger.dir(msg);
setLatestMessage(msg);
}
: undefined;
const baseClasses =
'final-completion group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu';
const cardClasses =
'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4';
const chatSpaceClasses = maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5';
const baseClasses = {
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
card: 'relative w-full gap-1 rounded-lg border border-border-medium bg-surface-primary-alt p-2 md:w-1/2 md:gap-3 md:p-4',
chat: maximizeChatSpace
? 'w-full max-w-full md:px-5 lg:px-1 xl:px-5'
: 'md:max-w-[47rem] xl:max-w-[55rem]',
};
const conditionalClasses = {
latestCard: isLatestCard ? 'bg-surface-secondary' : '',
@ -123,8 +122,8 @@ const ContentRender = memo(
id={msg.messageId}
aria-label={`message-${msg.depth}-${msg.messageId}`}
className={cn(
baseClasses,
isCard === true ? cardClasses : chatSpaceClasses,
baseClasses.common,
isCard ? baseClasses.card : baseClasses.chat,
conditionalClasses.latestCard,
conditionalClasses.cardRender,
conditionalClasses.focus,
@ -139,26 +138,25 @@ const ContentRender = memo(
role={showCardRender ? 'button' : undefined}
tabIndex={showCardRender ? 0 : undefined}
>
{isLatestCard === true && (
{isLatestCard && (
<div className="absolute right-0 top-0 m-2 h-3 w-3 rounded-full bg-text-primary" />
)}
<div className="relative flex flex-shrink-0 flex-col items-end">
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
</div>
</div>
<div
className={cn(
'relative flex w-11/12 flex-col',
msg.isCreatedByUser === true ? '' : 'agent-turn',
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<div className="flex-col gap-1 md:gap-3">
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">
<ContentParts
edit={edit}
@ -174,31 +172,32 @@ const ContentRender = memo(
content={msg.content as Array<TMessageContentParts | undefined>}
/>
</div>
{(isSubmittingFamily || isSubmitting) && !(msg.children?.length ?? 0) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
{!(msg.children?.length ?? 0) && (isSubmittingFamily === true || isSubmitting) ? (
<PlaceholderRow isCard={isCard} />
) : (
<SubRow classes="text-xs">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
<HoverButtons
index={index}
isEditing={edit}
message={msg}
enterEdit={enterEdit}
isSubmitting={isSubmitting}
conversation={conversation ?? null}
regenerate={handleRegenerateMessage}
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
isLast={isLast}
/>
</SubRow>
)}
</div>
</div>
);

View file

@ -8,16 +8,10 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
return (
<button
onClick={scrollHandler}
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-850/90 dark:text-gray-200"
className="premium-scroll-button absolute bottom-5 right-1/2 cursor-pointer border border-border-light bg-surface-secondary"
aria-label="Scroll to bottom"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className="m-1 text-black dark:text-white"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-text-secondary">
<path
d="M17 13L12 18L7 13M12 6L12 17"
stroke="currentColor"

View file

@ -1,6 +1,6 @@
import { useState, memo } from 'react';
import { useRecoilState } from 'recoil';
import * as Select from '@ariakit/react/select';
import { Fragment, useState, memo } from 'react';
import { FileText, LogOut } from 'lucide-react';
import { LinkIcon, GearIcon, DropdownMenuSeparator } from '~/components';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
@ -23,7 +23,7 @@ function AccountSettings() {
const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
const avatarSrc = useAvatar(user);
const name = user?.avatar ?? user?.username ?? '';
const avatarSeed = user?.avatar || user?.name || user?.username || '';
return (
<Select.SelectProvider>
@ -34,7 +34,7 @@ function AccountSettings() {
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
{name.length === 0 ? (
{avatarSeed.length === 0 ? (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
@ -51,7 +51,7 @@ function AccountSettings() {
<img
className="rounded-full"
src={(user?.avatar ?? '') || avatarSrc}
alt={`${name}'s avatar`}
alt={`${user?.name || user?.username || user?.email || ''}'s avatar`}
/>
)}
</div>

View file

@ -5,10 +5,10 @@ import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useGetEndpointsQuery } from '~/data-provider';
import { useLocalize, useNewConvo } from '~/hooks';
import { icons } from '~/hooks/Endpoint/Icons';
import { NewChatIcon } from '~/components/svg';
import { cn } from '~/utils';
import store from '~/store';

View file

@ -1,5 +1,6 @@
import { memo } from 'react';
import CodeArtifacts from './CodeArtifacts';
import ChatBadges from './ChatBadges';
function Beta() {
return (
@ -7,6 +8,9 @@ function Beta() {
<div className="pb-3">
<CodeArtifacts />
</div>
{/* <div className="pb-3">
<ChatBadges />
</div> */}
</div>
);
}

View file

@ -0,0 +1,22 @@
import { useSetRecoilState } from 'recoil';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function ChatBadges() {
const setIsEditing = useSetRecoilState<boolean>(store.isEditingBadges);
const localize = useLocalize();
const handleEditChatBadges = () => {
setIsEditing(true);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_edit_chat_badges')}</div>
<Button variant="outline" onClick={handleEditChatBadges}>
{localize('com_ui_edit')}
</Button>
</div>
);
}

View file

@ -1,15 +1,82 @@
import { memo } from 'react';
import MaximizeChatSpace from './MaximizeChatSpace';
import FontSizeSelector from './FontSizeSelector';
import SendMessageKeyEnter from './EnterToSend';
import ShowCodeSwitch from './ShowCodeSwitch';
import { ForkSettings } from './ForkSettings';
import ChatDirection from './ChatDirection';
import ShowThinking from './ShowThinking';
import LaTeXParsing from './LaTeXParsing';
import ScrollButton from './ScrollButton';
import ModularChat from './ModularChat';
import SaveDraft from './SaveDraft';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.enterToSend,
localizationKey: 'com_nav_enter_to_send',
switchId: 'enterToSend',
hoverCardText: 'com_nav_info_enter_to_send',
key: 'enterToSend',
},
{
stateAtom: store.maximizeChatSpace,
localizationKey: 'com_nav_maximize_chat_space',
switchId: 'maximizeChatSpace',
hoverCardText: undefined,
key: 'maximizeChatSpace',
},
{
stateAtom: store.centerFormOnLanding,
localizationKey: 'com_nav_center_chat_input',
switchId: 'centerFormOnLanding',
hoverCardText: undefined,
key: 'centerFormOnLanding',
},
{
stateAtom: store.showThinking,
localizationKey: 'com_nav_show_thinking',
switchId: 'showThinking',
hoverCardText: undefined,
key: 'showThinking',
},
{
stateAtom: store.showCode,
localizationKey: 'com_nav_show_code',
switchId: 'showCode',
hoverCardText: undefined,
key: 'showCode',
},
{
stateAtom: store.LaTeXParsing,
localizationKey: 'com_nav_latex_parsing',
switchId: 'latexParsing',
hoverCardText: 'com_nav_info_latex_parsing',
key: 'latexParsing',
},
{
stateAtom: store.saveDrafts,
localizationKey: 'com_nav_save_drafts',
switchId: 'saveDrafts',
hoverCardText: 'com_nav_info_save_draft',
key: 'saveDrafts',
},
{
stateAtom: store.showScrollButton,
localizationKey: 'com_nav_scroll_button',
switchId: 'showScrollButton',
hoverCardText: undefined,
key: 'showScrollButton',
},
{
stateAtom: store.saveBadgesState,
localizationKey: 'com_nav_save_badges_state',
switchId: 'showBadges',
hoverCardText: 'com_nav_info_save_badges_state',
key: 'showBadges',
},
{
stateAtom: store.modularChat,
localizationKey: 'com_nav_modular_chat',
switchId: 'modularChat',
hoverCardText: undefined,
key: 'modularChat',
},
];
function Chat() {
return (
@ -20,31 +87,17 @@ function Chat() {
<div className="pb-3">
<ChatDirection />
</div>
<div className="pb-3">
<SendMessageKeyEnter />
</div>
<div className="pb-3">
<MaximizeChatSpace />
</div>
<div className="pb-3">
<ShowCodeSwitch />
</div>
<div className="pb-3">
<SaveDraft />
</div>
<div className="pb-3">
<ScrollButton />
</div>
{toggleSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
hoverCardText={config.hoverCardText}
switchId={config.switchId}
/>
</div>
))}
<ForkSettings />
<div className="pb-3">
<ModularChat />
</div>
<div className="pb-3">
<LaTeXParsing />
</div>
<div className="pb-3">
<ShowThinking />
</div>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui/Switch';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function SendMessageKeyEnter({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [enterToSend, setEnterToSend] = useRecoilState<boolean>(store.enterToSend);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setEnterToSend(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_enter_to_send')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_enter_to_send" />
</div>
<Switch
id="enterToSend"
checked={enterToSend}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="enterToSend"
/>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function LaTeXParsingSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [LaTeXParsing, setLaTeXParsing] = useRecoilState<boolean>(store.LaTeXParsing);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setLaTeXParsing(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_latex_parsing')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_latex_parsing" />
</div>
<Switch
id="LaTeXParsing"
checked={LaTeXParsing}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="LaTeXParsing"
/>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui/Switch';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function MaximizeChatSpace({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [maximizeChatSpace, setmaximizeChatSpace] = useRecoilState<boolean>(
store.maximizeChatSpace,
);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setmaximizeChatSpace(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_maximize_chat_space')}</div>
</div>
<Switch
id="maximizeChatSpace"
checked={maximizeChatSpace}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="maximizeChatSpace"
/>
</div>
);
}

View file

@ -1,33 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function ModularChatSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [modularChat, setModularChat] = useRecoilState<boolean>(store.modularChat);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setModularChat(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_modular_chat')} </div>
<Switch
id="modularChat"
checked={modularChat}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="modularChat"
/>
</div>
);
}

View file

@ -4,16 +4,16 @@ import { Switch } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function SaveDraft({
export default function SaveBadgesState({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [saveDrafts, setSaveDrafts] = useRecoilState<boolean>(store.saveDrafts);
const [saveBadgesState, setSaveBadgesState] = useRecoilState<boolean>(store.saveBadgesState);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSaveDrafts(value);
setSaveBadgesState(value);
if (onCheckedChange) {
onCheckedChange(value);
}
@ -22,15 +22,15 @@ export default function SaveDraft({
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_save_drafts')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_save_draft" />
<div>{localize('com_nav_save_badges_state')}</div>
<HoverCardSettings side="bottom" text="com_nav_info_save_badges_state" />
</div>
<Switch
id="saveDrafts"
checked={saveDrafts}
id="saveBadgesState"
checked={saveBadgesState}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="saveDrafts"
data-testid="saveBadgesState"
/>
</div>
);

View file

@ -1,35 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui/Switch';
import useLocalize from '~/hooks/useLocalize';
import store from '~/store';
export default function ScrollButton({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [showScrollButton, setShowScrollButton] = useRecoilState<boolean>(store.showScrollButton);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setShowScrollButton(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize('com_nav_scroll_button')}</div>
</div>
<Switch
id="scrollButton"
checked={showScrollButton}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="scrollButton"
/>
</div>
);
}

View file

@ -1,34 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '../HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function ShowCodeSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [showCode, setShowCode] = useRecoilState<boolean>(store.showCode);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setShowCode(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_show_code')} </div>
<Switch
id="showCode"
checked={showCode}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="showCode"
/>
</div>
);
}

View file

@ -1,38 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render, fireEvent } from 'test/layout-test-utils';
import AutoScrollSwitch from './AutoScrollSwitch';
import { RecoilRoot } from 'recoil';
describe('AutoScrollSwitch', () => {
/**
* Mock function to set the auto-scroll state.
*/
let mockSetAutoScroll: jest.Mock<void, [boolean]> | ((value: boolean) => void) | undefined;
beforeEach(() => {
mockSetAutoScroll = jest.fn();
});
it('renders correctly', () => {
const { getByTestId } = render(
<RecoilRoot>
<AutoScrollSwitch />
</RecoilRoot>,
);
expect(getByTestId('autoScroll')).toBeInTheDocument();
});
it('calls onCheckedChange when the switch is toggled', () => {
const { getByTestId } = render(
<RecoilRoot>
<AutoScrollSwitch onCheckedChange={mockSetAutoScroll} />
</RecoilRoot>,
);
const switchElement = getByTestId('autoScroll');
fireEvent.click(switchElement);
expect(mockSetAutoScroll).toHaveBeenCalledWith(true);
});
});

View file

@ -6,9 +6,34 @@ import HideSidePanelSwitch from './HideSidePanelSwitch';
import { ThemeContext, useLocalize } from '~/hooks';
import AutoScrollSwitch from './AutoScrollSwitch';
import ArchivedChats from './ArchivedChats';
import { Dropdown } from '~/components/ui';
import ToggleSwitch from '../ToggleSwitch';
import { Dropdown } from '~/components';
import store from '~/store';
const toggleSwitchConfigs = [
{
stateAtom: store.enableUserMsgMarkdown,
localizationKey: 'com_nav_user_msg_markdown',
switchId: 'enableUserMsgMarkdown',
hoverCardText: undefined,
key: 'enableUserMsgMarkdown',
},
{
stateAtom: store.autoScroll,
localizationKey: 'com_nav_auto_scroll',
switchId: 'autoScroll',
hoverCardText: undefined,
key: 'autoScroll',
},
{
stateAtom: store.hideSidePanel,
localizationKey: 'com_nav_hide_panel',
switchId: 'hideSidePanel',
hoverCardText: undefined,
key: 'hideSidePanel',
},
];
export const ThemeSelector = ({
theme,
onChange,
@ -57,7 +82,10 @@ export const LangSelector = ({
{ value: 'de-DE', label: localize('com_nav_lang_german') },
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
{ value: 'et-EE', label: localize('com_nav_lang_estonian') },
{ value: 'fa-IR', label: localize('com_nav_lang_persian') },
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
{ value: 'he-HE', label: localize('com_nav_lang_hebrew') },
{ value: 'hu-HU', label: localize('com_nav_lang_hungarian') },
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
@ -72,7 +100,6 @@ export const LangSelector = ({
{ value: 'tr-TR', label: localize('com_nav_lang_turkish') },
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
{ value: 'he-HE', label: localize('com_nav_lang_hebrew') },
{ value: 'fi-FI', label: localize('com_nav_lang_finnish') },
];
@ -126,15 +153,16 @@ function General() {
<div className="pb-3">
<LangSelector langcode={langcode} onChange={changeLang} />
</div>
<div className="pb-3">
<UserMsgMarkdownSwitch />
</div>
<div className="pb-3">
<AutoScrollSwitch />
</div>
<div className="pb-3">
<HideSidePanelSwitch />
</div>
{toggleSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
hoverCardText={config.hoverCardText}
switchId={config.switchId}
/>
</div>
))}
<div className="pb-3">
<ArchivedChats />
</div>

View file

@ -0,0 +1,49 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from './HoverCardSettings';
import useLocalize from '~/hooks/useLocalize';
import { Switch } from '~/components/ui';
import { RecoilState } from 'recoil';
interface ToggleSwitchProps {
stateAtom: RecoilState<boolean>;
localizationKey: string;
hoverCardText?: string;
switchId: string;
onCheckedChange?: (value: boolean) => void;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
stateAtom,
localizationKey,
hoverCardText,
switchId,
onCheckedChange,
}) => {
const [switchState, setSwitchState] = useRecoilState<boolean>(stateAtom);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSwitchState(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div>{localize(localizationKey as any)}</div>
{hoverCardText && <HoverCardSettings side="bottom" text={hoverCardText} />}
</div>
<Switch
id={switchId}
checked={switchState}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid={switchId}
/>
</div>
);
};
export default ToggleSwitch;

View file

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { TMessage, TPreset, Assistant, Agent } from 'librechat-data-provider';
import type { TMessage, Assistant, Agent } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
@ -14,11 +14,6 @@ export default function MessageIcon(
) {
const { message, conversation, assistant, agent } = props;
const assistantName = assistant ? (assistant.name as string | undefined) : '';
const assistantAvatar = assistant ? (assistant.metadata?.avatar as string | undefined) : '';
const agentName = agent ? (agent.name as string | undefined) : '';
const agentAvatar = agent ? (agent.metadata?.avatar as string | undefined) : '';
const messageSettings = useMemo(
() => ({
...(conversation ?? {}),
@ -33,7 +28,27 @@ export default function MessageIcon(
const iconURL = messageSettings.iconURL ?? '';
let endpoint = messageSettings.endpoint;
endpoint = getIconEndpoint({ endpointsConfig: undefined, iconURL, endpoint });
const assistantName = (assistant ? assistant.name : '') ?? '';
const assistantAvatar = (assistant ? assistant.metadata?.avatar : '') ?? '';
const agentName = (agent ? agent.name : '') ?? '';
const agentAvatar = (agent ? agent?.avatar?.filepath : '') ?? '';
const avatarURL = useMemo(() => {
let result = '';
if (assistant) {
result = assistantAvatar;
} else if (agent) {
result = agentAvatar;
}
return result;
}, [assistant, agent, assistantAvatar, agentAvatar]);
console.log('MessageIcon', {
endpoint,
iconURL,
assistantName,
assistantAvatar,
agentName,
agentAvatar,
});
if (message?.isCreatedByUser !== true && iconURL && iconURL.includes('http')) {
return (
<ConvoIconURL
@ -68,7 +83,7 @@ export default function MessageIcon(
<MessageEndpointIcon
{...messageSettings}
endpoint={endpoint}
iconURL={assistant == null ? undefined : assistantAvatar}
iconURL={avatarURL}
model={message?.model ?? conversation?.model}
assistantName={assistantName}
agentName={agentName}

View file

@ -1,88 +0,0 @@
import { useEffect, useMemo } from 'react';
import { EModelEndpoint, isAgentsEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { Agent } from 'librechat-data-provider';
import type { SwitcherProps, OptionWithIcon } from '~/common';
import { useSetIndexOptions, useSelectAgent, useLocalize } from '~/hooks';
import { useChatContext, useAgentsMapContext } from '~/Providers';
import ControlCombobox from '~/components/ui/ControlCombobox';
import Icon from '~/components/Endpoints/Icon';
export default function AgentSwitcher({ isCollapsed }: SwitcherProps) {
const localize = useLocalize();
const { setOption } = useSetIndexOptions();
const { index, conversation } = useChatContext();
const { agent_id: selectedAgentId = null, endpoint } = conversation ?? {};
const agentsMapResult = useAgentsMapContext();
const agentsMap = useMemo(() => {
return agentsMapResult ?? {};
}, [agentsMapResult]);
const { onSelect } = useSelectAgent();
const agents: Agent[] = useMemo(() => {
return Object.values(agentsMap) as Agent[];
}, [agentsMap]);
useEffect(() => {
if (selectedAgentId == null && agents.length > 0) {
let agent_id = localStorage.getItem(`${LocalStorageKeys.AGENT_ID_PREFIX}${index}`);
if (agent_id == null) {
agent_id = agents[0].id;
}
const agent = agentsMap[agent_id];
if (agent !== undefined && isAgentsEndpoint(endpoint as string) === true) {
setOption('model')('');
setOption('agent_id')(agent_id);
}
}
}, [index, agents, selectedAgentId, agentsMap, endpoint, setOption]);
const currentAgent = agentsMap[selectedAgentId ?? ''];
const agentOptions: OptionWithIcon[] = useMemo(
() =>
agents.map((agent: Agent) => {
return {
label: agent.name ?? '',
value: agent.id,
icon: (
<Icon
isCreatedByUser={false}
endpoint={EModelEndpoint.agents}
agentName={agent.name ?? ''}
iconURL={agent.avatar?.filepath}
/>
),
};
}),
[agents],
);
return (
<ControlCombobox
selectedValue={currentAgent?.id ?? ''}
displayValue={
agents.find((agent: Agent) => agent.id === selectedAgentId)?.name ??
localize('com_sidepanel_select_agent')
}
selectPlaceholder={localize('com_sidepanel_select_agent')}
searchPlaceholder={localize('com_agents_search_name')}
isCollapsed={isCollapsed}
ariaLabel={'agent'}
setValue={onSelect}
items={agentOptions}
iconClassName="assistant-item"
SelectIcon={
<Icon
isCreatedByUser={false}
endpoint={endpoint}
agentName={currentAgent?.name ?? ''}
iconURL={currentAgent?.avatar?.filepath ?? ''}
/>
}
/>
);
}

View file

@ -2,12 +2,13 @@ import { X, Link2, PlusCircle } from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import type { TMessage } from 'librechat-data-provider';
import type { AgentForm, OptionWithIcon } from '~/common';
import ControlCombobox from '~/components/ui/ControlCombobox';
import { HoverCard, HoverCardPortal, HoverCardContent, HoverCardTrigger } from '~/components/ui';
import MessageIcon from '~/components/Share/MessageIcon';
import { CircleHelpIcon } from '~/components/svg';
import { useAgentsMapContext } from '~/Providers';
import Icon from '~/components/Endpoints/Icon';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
@ -37,11 +38,14 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
label: agent?.name || '',
value: agent?.id,
icon: (
<Icon
endpoint={EModelEndpoint.agents}
agentName={agent?.name ?? ''}
iconURL={agent?.avatar?.filepath}
isCreatedByUser={false}
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={agent}
/>
),
}) as OptionWithIcon,
@ -88,11 +92,14 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
<div className="flex h-10 items-center justify-between rounded-md border border-border-medium bg-surface-primary-contrast px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<Icon
endpoint={EModelEndpoint.agents}
agentName={getAgentDetails(currentAgentId)?.name ?? ''}
iconURL={getAgentDetails(currentAgentId)?.avatar?.filepath}
isCreatedByUser={false}
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={currentAgentId ? agentsMap[currentAgentId] : undefined}
/>
</div>
<div className="font-medium text-text-primary">
@ -114,11 +121,14 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
items={selectableAgents}
displayValue={getAgentDetails(agentId)?.name ?? ''}
SelectIcon={
<Icon
endpoint={EModelEndpoint.agents}
isCreatedByUser={false}
agentName={getAgentDetails(agentId)?.name ?? ''}
iconURL={getAgentDetails(agentId)?.avatar?.filepath}
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={agentId ? agentsMap[agentId] : undefined}
/>
}
className="flex-1 border-border-heavy"

View file

@ -6,9 +6,9 @@ import type { TPlugin } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
import { useToastContext, useFileMapContext } from '~/Providers';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { icons } from '~/hooks/Endpoint/Icons';
import { processAgentOption } from '~/utils';
import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
@ -53,27 +53,27 @@ export default function AgentConfig({
const agent_id = useWatch({ control, name: 'id' });
const toolsEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.tools),
() => agentsConfig?.capabilities?.includes(AgentCapabilities.tools) ?? false,
[agentsConfig],
);
const actionsEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.actions),
() => agentsConfig?.capabilities?.includes(AgentCapabilities.actions) ?? false,
[agentsConfig],
);
const artifactsEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.artifacts) ?? false,
() => agentsConfig?.capabilities?.includes(AgentCapabilities.artifacts) ?? false,
[agentsConfig],
);
const ocrEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.ocr) ?? false,
() => agentsConfig?.capabilities?.includes(AgentCapabilities.ocr) ?? false,
[agentsConfig],
);
const fileSearchEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.file_search) ?? false,
() => agentsConfig?.capabilities?.includes(AgentCapabilities.file_search) ?? false,
[agentsConfig],
);
const codeEnabled = useMemo(
() => agentsConfig?.capabilities.includes(AgentCapabilities.execute_code) ?? false,
() => agentsConfig?.capabilities?.includes(AgentCapabilities.execute_code) ?? false,
[agentsConfig],
);

View file

@ -56,18 +56,24 @@ export default function AgentPanel({
const { control, handleSubmit, reset } = methods;
const agent_id = useWatch({ control, name: 'id' });
const allowedProviders = useMemo(
() => new Set(agentsConfig?.allowedProviders),
[agentsConfig?.allowedProviders],
);
const providers = useMemo(
() =>
Object.keys(endpointsConfig ?? {})
.filter(
(key) =>
!isAssistantsEndpoint(key) &&
(allowedProviders.size > 0 ? allowedProviders.has(key) : true) &&
key !== EModelEndpoint.agents &&
key !== EModelEndpoint.chatGPTBrowser &&
key !== EModelEndpoint.gptPlugins,
)
.map((provider) => createProviderOption(provider)),
[endpointsConfig],
[endpointsConfig, allowedProviders],
);
/* Mutations */

View file

@ -12,7 +12,7 @@ import { getEndpointField, cn } from '~/utils';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
export default function Parameters({
export default function ModelPanel({
setActivePanel,
providers,
models: modelsData,

View file

@ -1,92 +0,0 @@
import { useEffect, useMemo } from 'react';
import { isAssistantsEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { AssistantsEndpoint } from 'librechat-data-provider';
import type { SwitcherProps, AssistantListItem } from '~/common';
import { useSetIndexOptions, useSelectAssistant, useLocalize, useAssistantListMap } from '~/hooks';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import ControlCombobox from '~/components/ui/ControlCombobox';
import Icon from '~/components/Endpoints/Icon';
export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
const localize = useLocalize();
const { setOption } = useSetIndexOptions();
const { index, conversation } = useChatContext();
/* `selectedAssistant` must be defined with `null` to cause re-render on update */
const { assistant_id: selectedAssistant = null, endpoint } = conversation ?? {};
const assistantListMap = useAssistantListMap((res) =>
res.data.map(({ id, name, metadata }) => ({ id, name, metadata })),
);
const assistants: Omit<AssistantListItem, 'model'>[] = useMemo(
() => assistantListMap[endpoint ?? ''] ?? [],
[endpoint, assistantListMap],
);
const assistantMap = useAssistantsMapContext();
const { onSelect } = useSelectAssistant(endpoint as AssistantsEndpoint);
useEffect(() => {
if (!selectedAssistant && assistants && assistants.length && assistantMap) {
const assistant_id =
localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}${endpoint}`) ??
assistants[0]?.id ??
'';
const assistant = assistantMap[endpoint ?? ''][assistant_id];
if (!assistant) {
return;
}
if (!isAssistantsEndpoint(endpoint)) {
return;
}
setOption('model')(assistant.model);
setOption('assistant_id')(assistant_id);
}
}, [index, assistants, selectedAssistant, assistantMap, endpoint, setOption]);
const currentAssistant = assistantMap?.[endpoint ?? '']?.[selectedAssistant ?? ''];
const assistantOptions = useMemo(() => {
return assistants.map((assistant) => {
return {
label: (assistant.name as string | null) ?? '',
value: assistant.id,
icon: (
<Icon
isCreatedByUser={false}
endpoint={endpoint}
assistantName={(assistant.name as string | null) ?? ''}
iconURL={assistant.metadata?.avatar ?? ''}
/>
),
};
});
}, [assistants, endpoint]);
return (
<ControlCombobox
selectedValue={currentAssistant?.id ?? ''}
displayValue={
assistants.find((assistant) => assistant.id === selectedAssistant)?.name ??
localize('com_sidepanel_select_assistant')
}
selectPlaceholder={localize('com_sidepanel_select_assistant')}
searchPlaceholder={localize('com_assistants_search_name')}
isCollapsed={isCollapsed}
ariaLabel={'assistant'}
setValue={onSelect}
items={assistantOptions}
iconClassName="assistant-item"
SelectIcon={
<Icon
isCreatedByUser={false}
endpoint={endpoint}
assistantName={currentAssistant?.name ?? ''}
iconURL={currentAssistant?.metadata?.avatar ?? ''}
/>
}
/>
);
}

View file

@ -1,58 +0,0 @@
import { useMemo, useRef, useCallback } from 'react';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import type { SwitcherProps } from '~/common';
import ControlCombobox from '~/components/ui/ControlCombobox';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import { useChatContext } from '~/Providers';
import { mainTextareaId } from '~/common';
export default function ModelSwitcher({ isCollapsed }: SwitcherProps) {
const localize = useLocalize();
const modelsQuery = useGetModelsQuery();
const { conversation } = useChatContext();
const { setOption } = useSetIndexOptions();
const timeoutIdRef = useRef<NodeJS.Timeout>();
const { endpoint, model = null } = conversation ?? {};
const models = useMemo(() => {
return (modelsQuery.data?.[endpoint ?? ''] ?? []).map((model) => ({
label: model,
value: model,
}));
}, [modelsQuery, endpoint]);
const setModel = useCallback(
(model: string) => {
setOption('model')(model);
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(() => {
const textarea = document.getElementById(mainTextareaId);
if (textarea) {
textarea.focus();
}
}, 150);
},
[setOption],
);
return (
<ControlCombobox
displayValue={model ?? ''}
selectPlaceholder={localize('com_ui_select_model')}
searchPlaceholder={localize('com_ui_select_search_model')}
isCollapsed={isCollapsed}
ariaLabel={'model'}
selectedValue={model ?? ''}
setValue={setModel}
items={models}
SelectIcon={
<MinimalIcon
isCreatedByUser={false}
endpoint={endpoint}
// iconURL={} // for future preset icons
/>
}
/>
);
}

View file

@ -25,7 +25,7 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col opacity-100 transition-opacity">
<div className="scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20">
<div className="flex h-full w-full flex-col gap-1 px-3 pb-3.5 group-[[data-collapsed=true]]:items-center group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
<div className="flex h-full w-full flex-col gap-1 px-3 py-2.5 group-[[data-collapsed=true]]:items-center group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{links.map((link, index) => {
const variant = getVariant(link);
return isCollapsed ? (

View file

@ -479,7 +479,7 @@ const googleCol2: SettingsConfiguration = [
];
const openAI: SettingsConfiguration = [
openAIParams.chatGptLabel,
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
openAIParams.max_tokens,
@ -495,7 +495,7 @@ const openAI: SettingsConfiguration = [
const openAICol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
openAIParams.chatGptLabel,
librechat.modelLabel,
librechat.promptPrefix,
];

View file

@ -9,7 +9,7 @@ import { useGetEndpointsQuery } from '~/data-provider';
import NavToggle from '~/components/Nav/NavToggle';
import { cn, getEndpointField } from '~/utils';
import { useChatContext } from '~/Providers';
import Switcher from './Switcher';
import Nav from './Nav';
const defaultMinSize = 20;
@ -91,6 +91,7 @@ const SidePanel = ({
keyProvided,
endpointType,
interfaceConfig,
endpointsConfig,
});
const toggleNavVisible = useCallback(() => {
@ -163,27 +164,13 @@ const SidePanel = ({
localStorage.setItem('react-resizable-panels:collapsed', 'true');
}}
className={cn(
'sidenav hide-scrollbar border-l border-border-light bg-background transition-opacity',
'sidenav hide-scrollbar border-l border-border-light bg-background py-1 transition-opacity',
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse
? 'hidden min-w-0'
: 'opacity-100',
)}
>
{interfaceConfig.modelSelect === true && (
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-background',
isCollapsed ? 'h-[52px]' : 'px-2',
)}
>
<Switcher
isCollapsed={isCollapsed}
endpointKeyProvided={keyProvided}
endpoint={endpoint}
/>
</div>
)}
<Nav
resize={panelRef.current?.resize}
isCollapsed={isCollapsed}

Some files were not shown because too many files have changed in this diff Show more