mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-11 04:58:51 +01:00
Merge branch 'main' into feat/multi-lang-Terms-of-service
This commit is contained in:
commit
7c0324695a
258 changed files with 8260 additions and 3717 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
369
client/src/components/Chat/Input/BadgeRow.tsx
Normal file
369
client/src/components/Chat/Input/BadgeRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
85
client/src/components/Chat/Input/ConversationStarters.tsx
Normal file
85
client/src/components/Chat/Input/ConversationStarters.tsx
Normal 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;
|
||||
87
client/src/components/Chat/Input/EditBadges.tsx
Normal file
87
client/src/components/Chat/Input/EditBadges.tsx
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
44
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal file
44
client/src/components/Chat/Input/Files/AttachFileChat.tsx
Normal 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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
30
client/src/components/Chat/Input/Files/FileFormChat.tsx
Normal file
30
client/src/components/Chat/Input/Files/FileFormChat.tsx
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
247
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal file
247
client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx
Normal 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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
34
client/src/components/Chat/Menus/Endpoints/DialogManager.tsx
Normal file
34
client/src/components/Chat/Menus/Endpoints/DialogManager.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { getEndpointField } from '~/utils';
|
||||
|
||||
interface DialogManagerProps {
|
||||
keyDialogOpen: boolean;
|
||||
keyDialogEndpoint?: EModelEndpoint;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
endpointsConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
const DialogManager = ({
|
||||
keyDialogOpen,
|
||||
keyDialogEndpoint,
|
||||
onOpenChange,
|
||||
endpointsConfig,
|
||||
}: DialogManagerProps) => {
|
||||
return (
|
||||
<>
|
||||
{keyDialogEndpoint && (
|
||||
<SetKeyDialog
|
||||
open={keyDialogOpen}
|
||||
endpoint={keyDialogEndpoint}
|
||||
endpointType={getEndpointField(endpointsConfig, keyDialogEndpoint, 'type')}
|
||||
onOpenChange={onOpenChange}
|
||||
userProvideURL={getEndpointField(endpointsConfig, keyDialogEndpoint, 'userProvideURL')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogManager;
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { IconMapProps, AgentIconMapProps, IconsRecord } from '~/common';
|
||||
import { Feather } from 'lucide-react';
|
||||
import {
|
||||
MinimalPlugin,
|
||||
GPTIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
GoogleMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
AssistantIcon,
|
||||
LightningIcon,
|
||||
BedrockIcon,
|
||||
Sparkles,
|
||||
} from '~/components/svg';
|
||||
import UnknownIcon from './UnknownIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AssistantAvatar = ({
|
||||
className = '',
|
||||
assistantName = '',
|
||||
avatar = '',
|
||||
context,
|
||||
size,
|
||||
}: IconMapProps) => {
|
||||
if (assistantName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
|
||||
alt={assistantName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
} else if (assistantName) {
|
||||
return <AssistantIcon className={cn('text-token-secondary', className)} size={size} />;
|
||||
}
|
||||
|
||||
return <Sparkles className={cn(context === 'landing' ? 'icon-2xl' : '', className)} />;
|
||||
};
|
||||
|
||||
const AgentAvatar = ({ className = '', avatar = '', agentName, size }: AgentIconMapProps) => {
|
||||
if (agentName != null && agentName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
|
||||
alt={agentName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Feather className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
|
||||
};
|
||||
|
||||
const Bedrock = ({ className = '' }: IconMapProps) => {
|
||||
return <BedrockIcon className={cn(className, 'h-full w-full')} />;
|
||||
};
|
||||
|
||||
export const icons: IconsRecord = {
|
||||
[EModelEndpoint.azureOpenAI]: AzureMinimalIcon,
|
||||
[EModelEndpoint.openAI]: GPTIcon,
|
||||
[EModelEndpoint.gptPlugins]: MinimalPlugin,
|
||||
[EModelEndpoint.anthropic]: AnthropicIcon,
|
||||
[EModelEndpoint.chatGPTBrowser]: LightningIcon,
|
||||
[EModelEndpoint.google]: GoogleMinimalIcon,
|
||||
[EModelEndpoint.custom]: CustomMinimalIcon,
|
||||
[EModelEndpoint.assistants]: AssistantAvatar,
|
||||
[EModelEndpoint.azureAssistants]: AssistantAvatar,
|
||||
[EModelEndpoint.agents]: AgentAvatar,
|
||||
[EModelEndpoint.bedrock]: Bedrock,
|
||||
unknown: UnknownIcon,
|
||||
};
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import { cn, getConvoSwitchLogic, getEndpointField, getIconKey } from '~/utils';
|
||||
import { useLocalize, useUserKey, useDefaultConvo } from '~/hooks';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { icons } from './Icons';
|
||||
import store from '~/store';
|
||||
|
||||
type MenuItemProps = {
|
||||
title: string;
|
||||
value: EModelEndpoint;
|
||||
selected: boolean;
|
||||
description?: string;
|
||||
userProvidesKey: boolean;
|
||||
// iconPath: string;
|
||||
// hoverContent?: string;
|
||||
};
|
||||
|
||||
const MenuItem: FC<MenuItemProps> = ({
|
||||
title,
|
||||
value: endpoint,
|
||||
description,
|
||||
selected,
|
||||
userProvidesKey,
|
||||
...rest
|
||||
}) => {
|
||||
const modularChat = useRecoilValue(store.modularChat);
|
||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
|
||||
const { getExpiry } = useUserKey(endpoint);
|
||||
const localize = useLocalize();
|
||||
const expiryTime = getExpiry() ?? '';
|
||||
|
||||
const onSelectEndpoint = (newEndpoint?: EModelEndpoint) => {
|
||||
if (!newEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expiryTime) {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
const {
|
||||
template,
|
||||
shouldSwitch,
|
||||
isNewModular,
|
||||
newEndpointType,
|
||||
isCurrentModular,
|
||||
isExistingConversation,
|
||||
} = getConvoSwitchLogic({
|
||||
newEndpoint,
|
||||
modularChat,
|
||||
conversation,
|
||||
endpointsConfig,
|
||||
});
|
||||
|
||||
const isModular = isCurrentModular && isNewModular && shouldSwitch;
|
||||
if (isExistingConversation && isModular) {
|
||||
template.endpointType = newEndpointType;
|
||||
|
||||
const currentConvo = getDefaultConversation({
|
||||
/* target endpointType is necessary to avoid endpoint mixing */
|
||||
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
|
||||
preset: template,
|
||||
});
|
||||
|
||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
||||
newConversation({
|
||||
template: currentConvo,
|
||||
preset: currentConvo,
|
||||
keepLatestMessage: true,
|
||||
keepAddedConvos: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
newConversation({
|
||||
template: { ...(template as Partial<TConversation>) },
|
||||
keepAddedConvos: isModular,
|
||||
});
|
||||
};
|
||||
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType });
|
||||
const Icon = icons[iconKey];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'group m-1.5 flex max-h-[40px] cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-surface-hover',
|
||||
'radix-disabled:pointer-events-none radix-disabled:opacity-50',
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...rest}
|
||||
onClick={() => onSelectEndpoint(endpoint)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSelectEndpoint(endpoint);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon != null && (
|
||||
<Icon
|
||||
size={18}
|
||||
endpoint={endpoint}
|
||||
context={'menu-item'}
|
||||
className="icon-md shrink-0 dark:text-white"
|
||||
iconURL={getEndpointField(endpointsConfig, endpoint, 'iconURL')}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{title}
|
||||
<div className="text-token-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{userProvidesKey ? (
|
||||
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
aria-label={`${localize('com_endpoint_config_key')} for ${title}`}
|
||||
className={cn(
|
||||
'invisible flex gap-x-1 group-focus-within:visible group-hover:visible',
|
||||
selected ? 'visible' : '',
|
||||
expiryTime ? 'text-token-text-primary w-full rounded-lg p-2' : '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'invisible group-focus-within:visible group-hover:visible',
|
||||
expiryTime ? 'text-xs' : '',
|
||||
)}
|
||||
>
|
||||
{localize('com_endpoint_config_key')}
|
||||
</div>
|
||||
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{selected && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{(!userProvidesKey || expiryTime) && (
|
||||
<div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
||||
{!userProvidesKey && <div className="">{localize('com_ui_new_chat')}</div>}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{userProvidesKey && (
|
||||
<SetKeyDialog
|
||||
open={isDialogOpen}
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
onOpenChange={setDialogOpen}
|
||||
userProvideURL={getEndpointField(endpointsConfig, endpoint, 'userProvideURL')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
import { Close } from '@radix-ui/react-popover';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
alternateName,
|
||||
PermissionTypes,
|
||||
Permissions,
|
||||
} from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import MenuSeparator from '../UI/MenuSeparator';
|
||||
import { getEndpointField } from '~/utils';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
const EndpointItems: FC<{
|
||||
endpoints: Array<EModelEndpoint | undefined>;
|
||||
selected: EModelEndpoint | '';
|
||||
}> = ({ endpoints = [], selected }) => {
|
||||
const hasAccessToAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
return (
|
||||
<>
|
||||
{endpoints.map((endpoint, i) => {
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
} else if (!endpointsConfig?.[endpoint]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.agents && !hasAccessToAgents) {
|
||||
return null;
|
||||
}
|
||||
const userProvidesKey: boolean | null | undefined =
|
||||
getEndpointField(endpointsConfig, endpoint, 'userProvide') ?? false;
|
||||
return (
|
||||
<Close asChild key={`endpoint-${endpoint}`}>
|
||||
<div key={`endpoint-${endpoint}`}>
|
||||
<MenuItem
|
||||
key={`endpoint-item-${endpoint}`}
|
||||
title={alternateName[endpoint] || endpoint}
|
||||
value={endpoint}
|
||||
selected={selected === endpoint}
|
||||
data-testid={`endpoint-item-${endpoint}`}
|
||||
userProvidesKey={!!userProvidesKey}
|
||||
// description="With DALL·E, browsing and analysis"
|
||||
/>
|
||||
{i !== endpoints.length - 1 && <MenuSeparator />}
|
||||
</div>
|
||||
</Close>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointItems;
|
||||
107
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal file
107
client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import { EModelEndpoint, KnownEndpoints } from 'librechat-data-provider';
|
||||
import { CustomMinimalIcon } from '~/components/svg';
|
||||
import { IconContext } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const knownEndpointAssets = {
|
||||
[KnownEndpoints.anyscale]: '/assets/anyscale.png',
|
||||
[KnownEndpoints.apipie]: '/assets/apipie.png',
|
||||
[KnownEndpoints.cohere]: '/assets/cohere.png',
|
||||
[KnownEndpoints.deepseek]: '/assets/deepseek.svg',
|
||||
[KnownEndpoints.fireworks]: '/assets/fireworks.png',
|
||||
[KnownEndpoints.groq]: '/assets/groq.png',
|
||||
[KnownEndpoints.huggingface]: '/assets/huggingface.svg',
|
||||
[KnownEndpoints.mistral]: '/assets/mistral.png',
|
||||
[KnownEndpoints.mlx]: '/assets/mlx.png',
|
||||
[KnownEndpoints.ollama]: '/assets/ollama.png',
|
||||
[KnownEndpoints.openrouter]: '/assets/openrouter.png',
|
||||
[KnownEndpoints.perplexity]: '/assets/perplexity.png',
|
||||
[KnownEndpoints.shuttleai]: '/assets/shuttleai.png',
|
||||
[KnownEndpoints['together.ai']]: '/assets/together.png',
|
||||
[KnownEndpoints.unify]: '/assets/unify.webp',
|
||||
[KnownEndpoints.xai]: '/assets/xai.svg',
|
||||
};
|
||||
|
||||
const knownEndpointClasses = {
|
||||
[KnownEndpoints.cohere]: {
|
||||
[IconContext.landing]: 'p-2',
|
||||
},
|
||||
[KnownEndpoints.xai]: {
|
||||
[IconContext.landing]: 'p-2',
|
||||
[IconContext.menuItem]: 'bg-white',
|
||||
[IconContext.message]: 'bg-white',
|
||||
[IconContext.nav]: 'bg-white',
|
||||
},
|
||||
};
|
||||
|
||||
const getKnownClass = ({
|
||||
currentEndpoint,
|
||||
context = '',
|
||||
className,
|
||||
}: {
|
||||
currentEndpoint: string;
|
||||
context?: string;
|
||||
className: string;
|
||||
}) => {
|
||||
if (currentEndpoint === KnownEndpoints.openrouter) {
|
||||
return className;
|
||||
}
|
||||
|
||||
const match = knownEndpointClasses[currentEndpoint]?.[context] ?? '';
|
||||
const defaultClass = context === IconContext.landing ? '' : className;
|
||||
|
||||
return cn(match, defaultClass);
|
||||
};
|
||||
|
||||
function UnknownIcon({
|
||||
className = '',
|
||||
endpoint: _endpoint,
|
||||
iconURL = '',
|
||||
context,
|
||||
}: {
|
||||
iconURL?: string;
|
||||
className?: string;
|
||||
endpoint?: EModelEndpoint | string | null;
|
||||
context?: 'landing' | 'menu-item' | 'nav' | 'message';
|
||||
}) {
|
||||
const endpoint = _endpoint ?? '';
|
||||
if (!endpoint) {
|
||||
return <CustomMinimalIcon className={className} />;
|
||||
}
|
||||
|
||||
const currentEndpoint = endpoint.toLowerCase();
|
||||
|
||||
if (iconURL) {
|
||||
return <img className={className} src={iconURL} alt={`${endpoint} Icon`} />;
|
||||
}
|
||||
|
||||
const assetPath: string = knownEndpointAssets[currentEndpoint] ?? '';
|
||||
|
||||
if (!assetPath) {
|
||||
return <CustomMinimalIcon className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={getKnownClass({
|
||||
currentEndpoint,
|
||||
context: context,
|
||||
className,
|
||||
})}
|
||||
src={assetPath}
|
||||
alt={`${currentEndpoint} Icon`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(UnknownIcon);
|
||||
|
|
@ -0,0 +1,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`} />
|
||||
));
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
));
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './ModelSpecItem';
|
||||
export * from './EndpointModelItem';
|
||||
export * from './EndpointItem';
|
||||
export * from './SearchResults';
|
||||
212
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal file
212
client/src/components/Chat/Menus/Endpoints/utils.ts
Normal 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');
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
69
client/src/components/Chat/TemporaryChat.tsx
Normal file
69
client/src/components/Chat/TemporaryChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
22
client/src/components/Nav/SettingsTabs/Beta/ChatBadges.tsx
Normal file
22
client/src/components/Nav/SettingsTabs/Beta/ChatBadges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
49
client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx
Normal file
49
client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue