🧭 refactor: Modernize Nav/Header (#7094)

* refactor: streamline model preset handling in conversation setup

* refactor: integrate navigation and location hooks in chat functions and event handlers, prevent cache from fetching on final event handling

* fix: prevent adding code interpreter non-image output to file list on message attachment event, fix all unhandled edge cases when this is done (treating the file download as an image attachment, undefined fields, message tokenCount issues, use of `startsWith` on undefined "text") although it is now prevent altogether

* chore: remove unused jailbreak prop from MinimalIcon component in EndpointIcon

* feat: add new SVG icons (MobileSidebar, Sidebar, XAIcon), fix: xAI styling in dark vs. light modes, adjust styling of Landing icons

* fix: open conversation in new tab on navigation with ctrl/meta key

* refactor: update Nav & Header to use close/open sidebar buttons, as well as redesign "New Chat"/"Bookmarks" buttons to the top of the Nav, matching the latest design of ChatGPT for simplicity and to free up space

* chore: remove unused isToggleHovering state and simplify opacity logic in Nav component

* style: match mobile nav to mobile header
This commit is contained in:
Danny Avila 2025-04-27 14:03:25 -04:00 committed by GitHub
parent c0ebb434a6
commit 550c7cc68a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 361 additions and 298 deletions

View file

@ -418,6 +418,9 @@ class AnthropicClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
width: file.width,

View file

@ -318,6 +318,9 @@ class GoogleClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
}
this.augmentedPrompt = await this.contextHandlers.createContext();

View file

@ -455,6 +455,9 @@ class OpenAIClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
orderedMessages[i].tokenCount += this.calculateImageTokenCost({
width: file.width,

View file

@ -61,6 +61,14 @@ async function saveMessage(req, params, metadata) {
update.expiredAt = null;
}
if (update.tokenCount != null && isNaN(update.tokenCount)) {
logger.warn(
`Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`,
);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.tokenCount = 0;
}
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: req.user.id },
update,
@ -97,7 +105,9 @@ async function saveMessage(req, params, metadata) {
};
} catch (findError) {
// If the findOne also fails, log it but don't crash
logger.warn(`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`);
logger.warn(
`Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`,
);
return {
...params,
messageId: params.messageId,

View file

@ -364,7 +364,9 @@ class AgentClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
continue;
}
// orderedMessages[i].tokenCount += this.calculateImageTokenCost({
// width: file.width,
// height: file.height,

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor" class="bg-white"><path d="m3.005 8.858 8.783 12.544h3.904L6.908 8.858zM6.905 15.825 3 21.402h3.907l1.951-2.788zM16.585 2l-6.75 9.64 1.953 2.79L20.492 2zM17.292 7.965v13.437h3.2V3.395z"></path></svg>

Before

Width:  |  Height:  |  Size: 315 B

View file

@ -508,7 +508,10 @@ export interface ModelItemProps {
className?: string;
}
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
export type ContextType = {
navVisible: boolean;
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
};
export interface SwitcherProps {
endpoint?: t.EModelEndpoint | null;

View file

@ -3,7 +3,7 @@ import { useOutletContext } from 'react-router-dom';
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ContextType } from '~/common';
import ModelSelector from './Menus/Endpoints/ModelSelector';
import { PresetsMenu, HeaderNewChat } from './Menus';
import { PresetsMenu, HeaderNewChat, OpenSidebar } from './Menus';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import { useMediaQuery, useHasAccess } from '~/hooks';
@ -15,7 +15,7 @@ const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible } = useOutletContext<ContextType>();
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
@ -37,6 +37,7 @@ export default function Header() {
<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="mx-2 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && <HeaderNewChat />}
{<ModelSelector startupConfig={startupConfig} />}
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}

View file

@ -34,7 +34,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
label: localize('com_ui_upload_image_input'),
value: undefined,
icon: <ImageUpIcon className="icon-md" />,
condition: files.every((file) => file.type.startsWith('image/')),
condition: files.every((file) => file.type?.startsWith('image/')),
},
];
for (const capability of capabilities) {

View file

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { ArrowUpDown, Database } from 'lucide-react';
import { FileSources, FileContext } from 'librechat-data-provider';
@ -68,7 +69,7 @@ export const columns: ColumnDef<TFile>[] = [
},
cell: ({ row }) => {
const file = row.original;
if (file.type.startsWith('image')) {
if (file.type?.startsWith('image')) {
return (
<div className="flex gap-2">
<ImagePreview
@ -76,7 +77,7 @@ export const columns: ColumnDef<TFile>[] = [
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md"
source={file.source}
/>
<span className="self-center truncate ">{file.filename}</span>
<span className="self-center truncate">{file.filename}</span>
</div>
);
}

View file

@ -9,7 +9,7 @@ import { useLocalize, useAuthContext } from '~/hooks';
import { getIconEndpoint, getEntity } from '~/utils';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black';
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none ';
function getTextSizeClass(text: string | undefined | null) {
if (!text) {
@ -149,7 +149,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
>
<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`}
className={`flex ${textHasMultipleLines ? 'flex-col' : 'flex-col md:flex-row'} items-center justify-center gap-2`}
>
<div className={`relative size-10 justify-center ${textHasMultipleLines ? 'mb-2' : ''}`}>
<ConvoIcon
@ -159,7 +159,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
endpointsConfig={endpointsConfig}
containerClassName={containerClassName}
context="landing"
className="h-2/3 w-2/3"
className="h-2/3 w-2/3 text-black dark:text-white"
size={41}
/>
{startupConfig?.showBirthdayIcon && (

View file

@ -1,36 +1,43 @@
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 { Button, NewChatIcon } from '~/components';
import { TooltipAnchor, Button } from '~/components/ui';
import { NewChatIcon } from '~/components/svg';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
export default function HeaderNewChat() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const localize = useLocalize();
if (isSmallScreen) {
return null;
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
return (
<Button
size="icon"
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
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],
[],
);
newConversation();
}}
};
return (
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={clickHandler}
>
<NewChatIcon />
</Button>
}
/>
);
}

View file

@ -0,0 +1,33 @@
import { TooltipAnchor, Button } from '~/components/ui';
import { Sidebar } from '~/components/svg';
import { useLocalize } from '~/hooks';
export default function OpenSidebar({
setNavVisible,
}: {
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const localize = useLocalize();
return (
<TooltipAnchor
description={localize('com_nav_open_sidebar')}
render={
<Button
size="icon"
variant="outline"
data-testid="open-sidebar-button"
aria-label={localize('com_nav_open_sidebar')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
})
}
>
<Sidebar />
</Button>
}
/>
);
}

View file

@ -1,2 +1,3 @@
export { default as PresetsMenu } from './PresetsMenu';
export { default as OpenSidebar } from './OpenSidebar';
export { default as HeaderNewChat } from './HeaderNewChat';

View file

@ -150,7 +150,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
return (
<a
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
href={filepath?.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
{...props}
>
{children}

View file

@ -102,6 +102,9 @@ export default function Conversation({
const handleNavigation = (ctrlOrMetaKey: boolean) => {
if (ctrlOrMetaKey) {
toggleNav();
const baseUrl = window.location.origin;
const path = `/c/${conversationId}`;
window.open(baseUrl + path, '_blank');
return;
}

View file

@ -63,7 +63,6 @@ export default function EndpointIcon({
isCreatedByUser={false}
chatGptLabel={undefined}
modelLabel={undefined}
jailbreak={undefined}
/>
);
}

View file

@ -1,10 +1,12 @@
import { type FC } from 'react';
import { useMemo } from 'react';
import type { FC } from 'react';
import { useRecoilValue } from 'recoil';
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems';
import { TooltipAnchor } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
@ -19,33 +21,48 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
const localize = useLocalize();
const { data } = useGetConversationTags();
const conversation = useRecoilValue(store.conversationByIndex(0));
const label = useMemo(
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
[tags, localize],
);
return (
<Menu as="div" className="group relative">
{({ open }) => (
<>
<TooltipAnchor
description={label}
render={
<MenuButton
id="bookmark-menu-button"
aria-label={localize('com_ui_bookmarks')}
className={cn(
'mt-text-sm flex h-10 w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors duration-200 hover:bg-surface-active-alt',
open ? 'bg-surface-active-alt' : '',
isSmallScreen ? 'h-12' : '',
'flex items-center justify-center',
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-full border-none p-2 hover:bg-surface-hover md:rounded-xl',
open ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"
>
<div className="h-7 w-7 flex-shrink-0">
<div className="relative flex h-full items-center justify-center text-text-primary">
{tags.length > 0 ? (
<BookmarkFilledIcon className="h-4 w-4" aria-hidden="true" />
<BookmarkFilledIcon
/** `isSmallScreen` is used because lazy loading is not influencing `md:` prefix for some reason */
className={cn('text-text-primary', isSmallScreen ? 'icon-md-heavy' : 'icon-lg')}
aria-hidden="true"
/>
) : (
<BookmarkIcon className="h-4 w-4" aria-hidden="true" />
<BookmarkIcon
className={cn('text-text-primary', isSmallScreen ? 'icon-md-heavy' : 'icon-lg')}
aria-hidden="true"
/>
)}
</div>
</div>
<div className="grow overflow-hidden whitespace-nowrap text-left text-sm font-medium text-text-primary">
{tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')}
</div>
</MenuButton>
<MenuItems className="absolute left-0 top-full z-[100] mt-1 w-full translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none">
}
/>
<MenuItems
anchor="bottom"
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
>
{data && conversation && (
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TConversation, ConversationListResponse } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import {
useLocalize,
@ -13,7 +13,6 @@ import {
} from '~/hooks';
import { useConversationsInfiniteQuery } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import NavToggle from './NavToggle';
import SearchBar from './SearchBar';
import NewChat from './NewChat';
import { cn } from '~/utils';
@ -59,7 +58,6 @@ const Nav = memo(
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [isToggleHovering, setIsToggleHovering] = useState(false);
const [showLoading, setShowLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
@ -152,20 +150,21 @@ const Nav = memo(
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
const subHeaders = useMemo(
() => (
<>
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
{hasAccessToBookmarks && (
() => search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />,
[search.enabled, isSmallScreen],
);
const headerButtons = useMemo(
() =>
hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
<Suspense fallback={null}>
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
</Suspense>
</>
)}
</>
),
[search.enabled, hasAccessToBookmarks, isSmallScreen, tags, setTags],
[hasAccessToBookmarks, tags, isSmallScreen],
);
const [isSearchLoading, setIsSearchLoading] = useState(
@ -198,23 +197,19 @@ const Nav = memo(
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<div
className={cn(
'flex h-full flex-col transition-opacity',
isToggleHovering && !isSmallScreen ? 'opacity-50' : 'opacity-100',
)}
>
<div className="flex h-full flex-col transition-opacity">
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-3 pb-3.5"
className="flex h-full flex-col px-2 pb-3.5 md:px-3"
>
<div className="flex flex-1 flex-col" ref={outerContainerRef}>
<MemoNewChat
toggleNav={itemToggleNav}
isSmallScreen={isSmallScreen}
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<Conversations
conversations={conversations}
@ -235,15 +230,6 @@ const Nav = memo(
</div>
</div>
</div>
<NavToggle
isHovering={isToggleHovering}
setIsHovering={setIsToggleHovering}
onToggle={toggleNavVisible}
navVisible={navVisible}
className="fixed left-0 top-1/2 z-40 hidden md:flex"
/>
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
);

View file

@ -1,88 +1,27 @@
import React, { useMemo, useCallback } from 'react';
import { Search } from 'lucide-react';
import React, { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useNavigate } from 'react-router-dom';
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 ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useGetEndpointsQuery } from '~/data-provider';
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { TooltipAnchor, Button } from '~/components/ui';
import { useLocalize, useNewConvo } from '~/hooks';
import { icons } from '~/hooks/Endpoint/Icons';
import { NewChatIcon } from '~/components/svg';
import { cn } from '~/utils';
import store from '~/store';
const NewChatButtonIcon = React.memo(({ conversation }: { conversation: TConversation | null }) => {
const { data: endpointsConfig } = useGetEndpointsQuery();
const search = useRecoilValue(store.search);
const searchQuery = search.debouncedQuery;
const computedIcon = useMemo(() => {
if (searchQuery) {
return null;
}
let { endpoint = '' } = conversation ?? {};
const iconURL = conversation?.iconURL ?? '';
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
const Icon = icons[iconKey];
return { iconURL, endpoint, endpointType, endpointIconURL, Icon };
}, [searchQuery, conversation, endpointsConfig]);
if (searchQuery) {
return (
<div className="shadow-stroke relative flex h-7 w-7 items-center justify-center rounded-full bg-white text-black dark:bg-white">
<Search className="h-5 w-5" />
</div>
);
}
if (!computedIcon) {
return null;
}
const { iconURL, endpoint, endpointIconURL, Icon } = computedIcon;
return (
<div className="h-7 w-7 flex-shrink-0">
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
endpointIconURL={iconURL}
context="nav"
/>
) : (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
{endpoint && Icon && (
<Icon
size={41}
context="nav"
className="h-2/3 w-2/3"
endpoint={endpoint}
iconURL={endpointIconURL}
/>
)}
</div>
)}
</div>
);
});
export default function NewChat({
index = 0,
toggleNav,
subHeaders,
isSmallScreen,
headerButtons,
}: {
index?: number;
toggleNav: () => void;
isSmallScreen?: boolean;
subHeaders?: React.ReactNode;
isSmallScreen: boolean;
headerButtons?: React.ReactNode;
}) {
const queryClient = useQueryClient();
/** Note: this component needs an explicit index passed if using more than one */
@ -91,48 +30,64 @@ export default function NewChat({
const localize = useLocalize();
const { conversation } = store.useCreateConversationAtom(index);
const clickHandler = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
event.preventDefault();
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
newConvo();
navigate('/c/new');
if (isSmallScreen) {
toggleNav();
}
},
[queryClient, conversation, newConvo, navigate, toggleNav],
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
);
return (
<div className="sticky left-0 right-0 top-0 z-50 bg-surface-primary-alt pt-3.5">
<div className="pb-0.5 last:pb-0" style={{ transform: 'none' }}>
<a
href="/"
tabIndex={0}
data-testid="nav-new-chat-button"
onClick={clickHandler}
className={cn(
'group flex h-10 items-center gap-2 rounded-lg px-2 font-medium transition-colors duration-200 hover:bg-surface-hover',
isSmallScreen ? 'h-14' : '',
)}
aria-label={localize('com_ui_new_chat')}
<>
<div className="flex items-center justify-between py-[2px] md:py-2">
<TooltipAnchor
description={localize('com_nav_close_sidebar')}
render={
<Button
size="icon"
variant="outline"
data-testid="close-sidebar-button"
aria-label={localize('com_nav_close_sidebar')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
onClick={toggleNav}
>
<NewChatButtonIcon conversation={conversation} />
<div className="grow overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary">
{localize('com_ui_new_chat')}
<Sidebar className="max-md:hidden" />
<MobileSidebar className="m-1 inline-flex size-10 items-center justify-center md:hidden" />
</Button>
}
/>
<div className="flex">
{headerButtons}
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="nav-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
onClick={clickHandler}
>
<NewChatIcon className="icon-md md:h-6 md:w-6" />
</Button>
}
/>
</div>
<div className="flex gap-3">
<span className="flex items-center" data-state="closed">
<NewChatIcon className="size-5" />
</span>
</div>
</a>
</div>
{subHeaders != null ? subHeaders : null}
</div>
</>
);
}

View file

@ -24,7 +24,7 @@ export default function GroupSidePanel({
} & ReturnType<typeof usePromptGroupsNav>) {
const location = useLocation();
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
const isChatRoute = useMemo(() => location.pathname.startsWith('/c/'), [location.pathname]);
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
return (
<div

View file

@ -8,7 +8,7 @@ export default function PanelFileCell({ row }: { row: Row<TFile | undefined> })
const file = row.original;
return (
<div className="flex w-full items-center gap-2">
{file?.type.startsWith('image') === true ? (
{file?.type?.startsWith('image') === true ? (
<ImagePreview
url={file.filepath}
className="h-10 w-10 flex-shrink-0"

View file

@ -1,6 +1,6 @@
import React from 'react';
export default function AzureMinimalistIcon() {
export default function AnthropicMinimalIcon() {
return (
<svg
stroke="currentColor"

View file

@ -0,0 +1,19 @@
export default function MobileSidebar({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 8C3 7.44772 3.44772 7 4 7H20C20.5523 7 21 7.44772 21 8C21 8.55228 20.5523 9 20 9H4C3.44772 9 3 8.55228 3 8ZM3 16C3 15.4477 3.44772 15 4 15H14C14.5523 15 15 15.4477 15 16C15 16.5523 14.5523 17 14 17H4C3.44772 17 3 16.5523 3 16Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -1,43 +0,0 @@
export default function Panel({ open = false }) {
const openPanel = (
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
);
const closePanel = (
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
</svg>
);
if (open) {
return openPanel;
} else {
return closePanel;
}
}

View file

@ -0,0 +1,19 @@
export default function Sidebar({ className }: { className?: string }) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.85719 3H15.1428C16.2266 2.99999 17.1007 2.99998 17.8086 3.05782C18.5375 3.11737 19.1777 3.24318 19.77 3.54497C20.7108 4.02433 21.4757 4.78924 21.955 5.73005C22.2568 6.32234 22.3826 6.96253 22.4422 7.69138C22.5 8.39925 22.5 9.27339 22.5 10.3572V13.6428C22.5 14.7266 22.5 15.6008 22.4422 16.3086C22.3826 17.0375 22.2568 17.6777 21.955 18.27C21.4757 19.2108 20.7108 19.9757 19.77 20.455C19.1777 20.7568 18.5375 20.8826 17.8086 20.9422C17.1008 21 16.2266 21 15.1428 21H8.85717C7.77339 21 6.89925 21 6.19138 20.9422C5.46253 20.8826 4.82234 20.7568 4.23005 20.455C3.28924 19.9757 2.52433 19.2108 2.04497 18.27C1.74318 17.6777 1.61737 17.0375 1.55782 16.3086C1.49998 15.6007 1.49999 14.7266 1.5 13.6428V10.3572C1.49999 9.27341 1.49998 8.39926 1.55782 7.69138C1.61737 6.96253 1.74318 6.32234 2.04497 5.73005C2.52433 4.78924 3.28924 4.02433 4.23005 3.54497C4.82234 3.24318 5.46253 3.11737 6.19138 3.05782C6.89926 2.99998 7.77341 2.99999 8.85719 3ZM6.35424 5.05118C5.74907 5.10062 5.40138 5.19279 5.13803 5.32698C4.57354 5.6146 4.1146 6.07354 3.82698 6.63803C3.69279 6.90138 3.60062 7.24907 3.55118 7.85424C3.50078 8.47108 3.5 9.26339 3.5 10.4V13.6C3.5 14.7366 3.50078 15.5289 3.55118 16.1458C3.60062 16.7509 3.69279 17.0986 3.82698 17.362C4.1146 17.9265 4.57354 18.3854 5.13803 18.673C5.40138 18.8072 5.74907 18.8994 6.35424 18.9488C6.97108 18.9992 7.76339 19 8.9 19H9.5V5H8.9C7.76339 5 6.97108 5.00078 6.35424 5.05118ZM11.5 5V19H15.1C16.2366 19 17.0289 18.9992 17.6458 18.9488C18.2509 18.8994 18.5986 18.8072 18.862 18.673C19.4265 18.3854 19.8854 17.9265 20.173 17.362C20.3072 17.0986 20.3994 16.7509 20.4488 16.1458C20.4992 15.5289 20.5 14.7366 20.5 13.6V10.4C20.5 9.26339 20.4992 8.47108 20.4488 7.85424C20.3994 7.24907 20.3072 6.90138 20.173 6.63803C19.8854 6.07354 19.4265 5.6146 18.862 5.32698C18.5986 5.19279 18.2509 5.10062 17.6458 5.05118C17.0289 5.00078 16.2366 5 15.1 5H11.5ZM5 8.5C5 7.94772 5.44772 7.5 6 7.5H7C7.55229 7.5 8 7.94772 8 8.5C8 9.05229 7.55229 9.5 7 9.5H6C5.44772 9.5 5 9.05229 5 8.5ZM5 12C5 11.4477 5.44772 11 6 11H7C7.55229 11 8 11.4477 8 12C8 12.5523 7.55229 13 7 13H6C5.44772 13 5 12.5523 5 12Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -0,0 +1,16 @@
import React from 'react';
export default function XAIcon({ className = '' }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
fill="currentColor"
className={className}
>
<path d="m3.005 8.858 8.783 12.544h3.904L6.908 8.858zM6.905 15.825 3 21.402h3.907l1.951-2.788zM16.585 2l-6.75 9.64 1.953 2.79L20.492 2zM17.292 7.965v13.437h3.2V3.395z"></path>
</svg>
);
}

View file

@ -4,7 +4,8 @@ export { default as Plugin } from './Plugin';
export { default as GPTIcon } from './GPTIcon';
export { default as EditIcon } from './EditIcon';
export { default as DataIcon } from './DataIcon';
export { default as Panel } from './Panel';
export { default as Sidebar } from './Sidebar';
export { default as MobileSidebar } from './MobileSidebar';
export { default as Spinner } from './Spinner';
export { default as Clipboard } from './Clipboard';
export { default as CheckMark } from './CheckMark';
@ -56,3 +57,4 @@ export { default as SpeechIcon } from './SpeechIcon';
export { default as SaveIcon } from './SaveIcon';
export { default as CircleHelpIcon } from './CircleHelpIcon';
export { default as BedrockIcon } from './BedrockIcon';
export { default as XAIcon } from './XAIcon';

View file

@ -33,7 +33,8 @@ const ModelParameters: React.FC<ModelParametersProps> = ({
const rangeRef = useRef<HTMLInputElement>(null);
const id = `model-parameter-${ariaLabel.toLowerCase().replace(/\s+/g, '-')}`;
const displayLabel = label.startsWith('com_') ? localize(label as TranslationKeys) : label;
const displayLabel =
label && label.startsWith('com_') ? localize(label as TranslationKeys) : label;
const getDecimalPlaces = (num: number) => {
const match = ('' + num).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);

View file

@ -25,6 +25,7 @@ import store, { useGetEphemeralAgent } from '~/store';
import { getArtifactsMode } from '~/utils/artifacts';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
import { useNavigate } from 'react-router-dom';
const logChatRequest = (request: Record<string, unknown>) => {
logger.log('=====================================\nAsk function called with:');
@ -69,6 +70,7 @@ export default function useChatFunctions({
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const includeShadcnui = useRecoilValue(store.includeShadcnui);
const customPromptMode = useRecoilValue(store.customPromptMode);
const navigate = useNavigate();
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const setFilesToDelete = useSetFilesToDelete();
@ -146,6 +148,7 @@ export default function useChatFunctions({
parentMessageId = Constants.NO_PARENT;
currentMessages = [];
conversationId = null;
navigate('/c/new');
}
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;

View file

@ -23,10 +23,10 @@ export default function useChatHelpers(index = 0, paramId?: string) {
const { conversation, setConversation } = useCreateConversationAtom(index);
const { conversationId } = conversation ?? {};
const queryParam = paramId === 'new' ? paramId : conversationId ?? paramId ?? '';
const queryParam = paramId === 'new' ? paramId : (conversationId ?? paramId ?? '');
/* Messages: here simply to fetch, don't export and use `getMessages()` instead */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { data: _messages } = useGetMessagesByConvoId(conversationId ?? '', {
enabled: isAuthenticated,
});
@ -41,7 +41,7 @@ export default function useChatHelpers(index = 0, paramId?: string) {
const setMessages = useCallback(
(messages: TMessage[]) => {
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, queryParam], messages);
if (queryParam === 'new') {
if (queryParam === 'new' && conversationId && conversationId !== 'new') {
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, conversationId], messages);
}
},

View file

@ -1,6 +1,6 @@
import { memo } from 'react';
import { EModelEndpoint, KnownEndpoints } from 'librechat-data-provider';
import { CustomMinimalIcon } from '~/components/svg';
import { CustomMinimalIcon, XAIcon } from '~/components/svg';
import { IconContext } from '~/common';
import { cn } from '~/utils';
@ -20,7 +20,6 @@ const knownEndpointAssets = {
[KnownEndpoints.shuttleai]: '/assets/shuttleai.png',
[KnownEndpoints['together.ai']]: '/assets/together.png',
[KnownEndpoints.unify]: '/assets/unify.webp',
[KnownEndpoints.xai]: '/assets/xai.svg',
};
const knownEndpointClasses = {
@ -29,9 +28,6 @@ const knownEndpointClasses = {
},
[KnownEndpoints.xai]: {
[IconContext.landing]: 'p-2',
[IconContext.menuItem]: 'bg-white',
[IconContext.message]: 'bg-white',
[IconContext.nav]: 'bg-white',
},
};
@ -72,6 +68,18 @@ function UnknownIcon({
const currentEndpoint = endpoint.toLowerCase();
if (currentEndpoint === KnownEndpoints.xai) {
return (
<XAIcon
className={getKnownClass({
currentEndpoint,
context: context,
className,
})}
/>
);
}
if (iconURL) {
return <img className={className} src={iconURL} alt={`${endpoint} Icon`} />;
}

View file

@ -10,7 +10,7 @@ export default function useAttachmentHandler(queryClient?: QueryClient) {
return ({ data }: { data: TAttachment; submission: EventSubmission }) => {
const { messageId } = data;
if (queryClient) {
if (queryClient && !data?.filepath?.startsWith('/api/files')) {
queryClient.setQueryData([QueryKeys.files], (oldData: TAttachment[] | undefined) => {
return [data, ...(oldData || [])];
});

View file

@ -1,7 +1,7 @@
import { v4 } from 'uuid';
import { useCallback, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
@ -172,6 +172,8 @@ export default function useEventHandlers({
const { announcePolite } = useLiveAnnouncer();
const applyAgentTemplate = useApplyNewAgentTemplate();
const setAbortScroll = useSetRecoilState(store.abortScroll);
const navigate = useNavigate();
const location = useLocation();
const lastAnnouncementTimeRef = useRef(Date.now());
const { conversationId: paramId } = useParams();
@ -421,6 +423,7 @@ export default function useEventHandlers({
announcePolite,
setConversation,
resetLatestMessage,
applyAgentTemplate,
],
);
@ -449,12 +452,20 @@ export default function useEventHandlers({
announcePolite({ message: getAllContentText(responseMessage) });
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
let finalMessages: TMessage[] = [];
if (runMessages) {
setMessages([...runMessages]);
finalMessages = [...runMessages];
} else if (isRegenerate && responseMessage) {
setMessages([...messages, responseMessage]);
finalMessages = [...messages, responseMessage];
} else if (requestMessage != null && responseMessage != null) {
setMessages([...messages, requestMessage, responseMessage]);
finalMessages = [...messages, requestMessage, responseMessage];
}
if (finalMessages.length > 0) {
setMessages(finalMessages);
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversation.conversationId],
finalMessages,
);
}
const isNewConvo = conversation.conversationId !== submissionConvo.conversationId;
@ -476,8 +487,8 @@ export default function useEventHandlers({
}
if (setConversation && isAddedRequest !== true) {
if (window.location.pathname === '/c/new') {
window.history.pushState({}, '', '/c/' + conversation.conversationId);
if (location.pathname === '/c/new') {
navigate(`/c/${conversation.conversationId}`, { replace: true });
}
setConversation((prevState) => {
@ -502,16 +513,18 @@ export default function useEventHandlers({
setIsSubmitting(false);
},
[
genTitle,
queryClient,
getMessages,
setMessages,
setCompleted,
isAddedRequest,
announcePolite,
setConversation,
setIsSubmitting,
setShowStopButton,
setCompleted,
getMessages,
announcePolite,
genTitle,
setConversation,
isAddedRequest,
setIsSubmitting,
setMessages,
queryClient,
location.pathname,
navigate,
],
);
@ -599,7 +612,7 @@ export default function useEventHandlers({
setIsSubmitting(false);
return;
},
[setMessages, paramId, setIsSubmitting, setCompleted, newConversation],
[setCompleted, setMessages, paramId, newConversation, setIsSubmitting, getMessages],
);
const abortConversation = useCallback(
@ -698,7 +711,15 @@ export default function useEventHandlers({
setIsSubmitting(false);
}
},
[token, setIsSubmitting, finalHandler, cancelHandler, setMessages, newConversation],
[
finalHandler,
newConversation,
setIsSubmitting,
token,
cancelHandler,
getMessages,
setMessages,
],
);
return {

View file

@ -22,8 +22,8 @@ import {
getEndpointField,
buildDefaultConvo,
getDefaultEndpoint,
getModelSpecPreset,
getDefaultModelSpec,
getModelSpecIconURL,
updateLastSelectedModel,
} from '~/utils';
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
@ -231,11 +231,7 @@ const useNewConvo = (index = 0) => {
(startupConfig.interface?.modelSelect ?? true) !== true) &&
defaultModelSpec
) {
preset = {
...defaultModelSpec.preset,
iconURL: getModelSpecIconURL(defaultModelSpec),
spec: defaultModelSpec.name,
} as TConversation;
preset = getModelSpecPreset(defaultModelSpec);
}
if (conversation.conversationId === 'new' && !modelsData) {

View file

@ -9,8 +9,8 @@ import {
useGetStartupConfig,
useGetEndpointsQuery,
} from '~/data-provider';
import { getDefaultModelSpec, getModelSpecPreset, logger } from '~/utils';
import { useNewConvo, useAppStartup, useAssistantListMap } from '~/hooks';
import { getDefaultModelSpec, getModelSpecIconURL, logger } from '~/utils';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
import useAuthRedirect from './useAuthRedirect';
@ -65,15 +65,7 @@ export default function ChatRoute() {
newConversation({
modelsData: modelsQuery.data,
template: conversation ? conversation : undefined,
...(spec
? {
preset: {
...spec.preset,
iconURL: getModelSpecIconURL(spec),
spec: spec.name,
},
}
: {}),
...(spec ? { preset: getModelSpecPreset(spec) } : {}),
});
hasSetConversation.current = true;
@ -97,15 +89,7 @@ export default function ChatRoute() {
newConversation({
modelsData: modelsQuery.data,
template: conversation ? conversation : undefined,
...(spec
? {
preset: {
...spec.preset,
iconURL: getModelSpecIconURL(spec),
spec: spec.name,
},
}
: {}),
...(spec ? { preset: getModelSpecPreset(spec) } : {}),
});
hasSetConversation.current = true;
} else if (

View file

@ -203,6 +203,17 @@ export function getDefaultModelSpec(startupConfig?: t.TStartupConfig) {
return list?.find((spec) => spec.name === lastConversationSetup.spec);
}
export function getModelSpecPreset(modelSpec?: t.TModelSpec) {
if (!modelSpec) {
return;
}
return {
...modelSpec.preset,
spec: modelSpec.name,
iconURL: getModelSpecIconURL(modelSpec),
};
}
/** Gets the default spec iconURL by order or definition.
*
* First, the admin defined default, then last selected spec, followed by first spec