mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-30 23:28:52 +01:00
Merge branch 'dev' into feat/context-window-ui
This commit is contained in:
commit
cb8322ca85
407 changed files with 25479 additions and 19894 deletions
12
client/src/@types/i18next.d.ts
vendored
12
client/src/@types/i18next.d.ts
vendored
|
|
@ -1,9 +1,9 @@
|
|||
import { defaultNS, resources } from '~/locales/i18n';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true
|
||||
}
|
||||
}
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: typeof defaultNS;
|
||||
resources: typeof resources.en;
|
||||
strictKeyChecks: true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ const App = () => {
|
|||
const { setError } = useApiErrorBoundary();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Always attempt network requests, even when navigator.onLine is false
|
||||
// This is needed because localhost is reachable without WiFi
|
||||
networkMode: 'always',
|
||||
},
|
||||
mutations: {
|
||||
networkMode: 'always',
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import useAddedResponse from '~/hooks/Chat/useAddedResponse';
|
||||
type TAddedChatContext = ReturnType<typeof useAddedResponse>;
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { ConvoGenerator } from '~/common';
|
||||
|
||||
type TAddedChatContext = {
|
||||
conversation: TConversation | null;
|
||||
setConversation: SetterOrUpdater<TConversation | null>;
|
||||
generateConversation: ConvoGenerator;
|
||||
};
|
||||
|
||||
export const AddedChatContext = createContext<TAddedChatContext>({} as TAddedChatContext);
|
||||
export const useAddedChatContext = () => useContext(AddedChatContext);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentsMapContext } from './AgentsMapContext';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface DragDropContextValue {
|
||||
|
|
@ -9,6 +10,7 @@ interface DragDropContextValue {
|
|||
agentId: string | null | undefined;
|
||||
endpoint: string | null | undefined;
|
||||
endpointType?: EModelEndpoint | undefined;
|
||||
useResponsesApi?: boolean;
|
||||
}
|
||||
|
||||
const DragDropContext = createContext<DragDropContextValue | undefined>(undefined);
|
||||
|
|
@ -16,6 +18,7 @@ const DragDropContext = createContext<DragDropContextValue | undefined>(undefine
|
|||
export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
|
||||
const endpointType = useMemo(() => {
|
||||
return (
|
||||
|
|
@ -24,6 +27,34 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
}, [conversation?.endpoint, endpointsConfig]);
|
||||
|
||||
const needsAgentFetch = useMemo(() => {
|
||||
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
||||
if (!isAgents || !conversation?.agent_id) {
|
||||
return false;
|
||||
}
|
||||
const agent = agentsMap?.[conversation.agent_id];
|
||||
return !agent?.model_parameters;
|
||||
}, [conversation?.endpoint, conversation?.agent_id, agentsMap]);
|
||||
|
||||
const { data: agentData } = useGetAgentByIdQuery(conversation?.agent_id, {
|
||||
enabled: needsAgentFetch,
|
||||
});
|
||||
|
||||
const useResponsesApi = useMemo(() => {
|
||||
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
||||
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
|
||||
return conversation?.useResponsesApi;
|
||||
}
|
||||
const agent = agentData || agentsMap?.[conversation.agent_id];
|
||||
return agent?.model_parameters?.useResponsesApi;
|
||||
}, [
|
||||
conversation?.endpoint,
|
||||
conversation?.agent_id,
|
||||
conversation?.useResponsesApi,
|
||||
agentData,
|
||||
agentsMap,
|
||||
]);
|
||||
|
||||
/** Context value only created when conversation fields change */
|
||||
const contextValue = useMemo<DragDropContextValue>(
|
||||
() => ({
|
||||
|
|
@ -31,8 +62,15 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
|||
agentId: conversation?.agent_id,
|
||||
endpoint: conversation?.endpoint,
|
||||
endpointType: endpointType,
|
||||
useResponsesApi: useResponsesApi,
|
||||
}),
|
||||
[conversation?.conversationId, conversation?.agent_id, conversation?.endpoint, endpointType],
|
||||
[
|
||||
conversation?.conversationId,
|
||||
conversation?.agent_id,
|
||||
conversation?.endpoint,
|
||||
useResponsesApi,
|
||||
endpointType,
|
||||
],
|
||||
);
|
||||
|
||||
return <DragDropContext.Provider value={contextValue}>{children}</DragDropContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useAddedChatContext } from './AddedChatContext';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface MessagesViewContextValue {
|
||||
|
|
@ -9,7 +8,6 @@ interface MessagesViewContextValue {
|
|||
|
||||
/** Submission and control states */
|
||||
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
|
||||
isSubmittingFamily: boolean;
|
||||
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
|
||||
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
|
||||
|
||||
|
|
@ -34,13 +32,12 @@ export type { MessagesViewContextValue };
|
|||
|
||||
export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
|
||||
const chatContext = useChatContext();
|
||||
const addedChatContext = useAddedChatContext();
|
||||
|
||||
const {
|
||||
ask,
|
||||
index,
|
||||
regenerate,
|
||||
isSubmitting: isSubmittingRoot,
|
||||
isSubmitting,
|
||||
conversation,
|
||||
latestMessage,
|
||||
setAbortScroll,
|
||||
|
|
@ -51,8 +48,6 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
|
|||
setMessages,
|
||||
} = chatContext;
|
||||
|
||||
const { isSubmitting: isSubmittingAdditional } = addedChatContext;
|
||||
|
||||
/** Memoize conversation-related values */
|
||||
const conversationValues = useMemo(
|
||||
() => ({
|
||||
|
|
@ -65,12 +60,11 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
|
|||
/** Memoize submission states */
|
||||
const submissionStates = useMemo(
|
||||
() => ({
|
||||
isSubmitting: isSubmittingRoot,
|
||||
isSubmittingFamily: isSubmittingRoot || isSubmittingAdditional,
|
||||
abortScroll,
|
||||
isSubmitting,
|
||||
setAbortScroll,
|
||||
}),
|
||||
[isSubmittingRoot, isSubmittingAdditional, abortScroll, setAbortScroll],
|
||||
[isSubmitting, abortScroll, setAbortScroll],
|
||||
);
|
||||
|
||||
/** Memoize message operations (these are typically stable references) */
|
||||
|
|
@ -127,11 +121,10 @@ export function useMessagesConversation() {
|
|||
|
||||
/** Hook for components that only need submission states */
|
||||
export function useMessagesSubmission() {
|
||||
const { isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll } =
|
||||
useMessagesViewContext();
|
||||
const { isSubmitting, abortScroll, setAbortScroll } = useMessagesViewContext();
|
||||
return useMemo(
|
||||
() => ({ isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll }),
|
||||
[isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll],
|
||||
() => ({ isSubmitting, abortScroll, setAbortScroll }),
|
||||
[isSubmitting, abortScroll, setAbortScroll],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { RefObject } from 'react';
|
||||
import { Constants, FileSources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { FileSources, EModelEndpoint, isEphemeralAgentId } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type * as InputNumberPrimitive from 'rc-input-number';
|
||||
import type { SetterOrUpdater, RecoilState } from 'recoil';
|
||||
|
|
@ -10,7 +10,7 @@ import type { TranslationKeys } from '~/hooks';
|
|||
import { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
|
||||
export function isEphemeralAgent(agentId: string | null | undefined): boolean {
|
||||
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
|
||||
return isEphemeralAgentId(agentId);
|
||||
}
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
|
|
@ -356,6 +356,8 @@ export type TOptions = {
|
|||
isResubmission?: boolean;
|
||||
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
|
||||
overrideFiles?: t.TMessage['files'];
|
||||
/** Added conversation for multi-convo feature - sent to server as part of submission payload */
|
||||
addedConvo?: t.TConversation;
|
||||
};
|
||||
|
||||
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Label } from '@librechat/client';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Label, OGDialog, OGDialogTrigger } from '@librechat/client';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
|
||||
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
||||
import AgentDetailContent from './AgentDetailContent';
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: t.Agent; // The agent data to display
|
||||
onClick: () => void; // Callback when card is clicked
|
||||
className?: string; // Additional CSS classes
|
||||
agent: t.Agent;
|
||||
onSelect?: (agent: t.Agent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component to display agent information
|
||||
* Card component to display agent information with integrated detail dialog
|
||||
*/
|
||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, onSelect, className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const { categories } = useAgentCategories();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (!agent.category) return '';
|
||||
|
|
@ -31,82 +33,89 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
|
||||
}, [agent.category, categories, localize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative h-40 overflow-hidden rounded-xl border border-border-light',
|
||||
'cursor-pointer shadow-sm transition-all duration-200 hover:border-border-medium hover:shadow-lg',
|
||||
'bg-surface-tertiary hover:bg-surface-hover',
|
||||
'space-y-3 p-4',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={localize('com_agents_agent_card_label', {
|
||||
name: agent.name,
|
||||
description: agent.description ?? '',
|
||||
})}
|
||||
aria-describedby={`agent-${agent.id}-description`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Left column: Avatar and Category */}
|
||||
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
|
||||
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||
const displayName = getContactDisplayName(agent);
|
||||
|
||||
{/* Category tag */}
|
||||
{agent.category && (
|
||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (open && onSelect) {
|
||||
onSelect(agent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<OGDialogTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-32 gap-5 overflow-hidden rounded-xl',
|
||||
'cursor-pointer select-none px-6 py-4',
|
||||
'bg-surface-tertiary transition-colors duration-150 hover:bg-surface-hover',
|
||||
'md:h-36 lg:h-40',
|
||||
'[&_*]:cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
aria-label={localize('com_agents_agent_card_label', {
|
||||
name: agent.name,
|
||||
description: agent.description ?? '',
|
||||
})}
|
||||
aria-describedby={agent.description ? `agent-${agent.id}-description` : undefined}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Category badge - top right */}
|
||||
{categoryLabel && (
|
||||
<span className="absolute right-4 top-3 rounded-md bg-surface-hover px-2 py-0.5 text-xs text-text-secondary">
|
||||
{categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<div className="overflow-hidden rounded-full shadow-[0_0_15px_rgba(0,0,0,0.3)] dark:shadow-[0_0_15px_rgba(0,0,0,0.5)]">
|
||||
{renderAgentAvatar(agent, { size: 'sm', showBorder: false })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
|
||||
{/* Agent name */}
|
||||
<Label className="line-clamp-2 text-base font-semibold text-text-primary md:text-lg">
|
||||
{agent.name}
|
||||
</Label>
|
||||
|
||||
{/* Agent description */}
|
||||
{agent.description && (
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className="mt-0.5 line-clamp-2 text-sm leading-snug text-text-secondary md:line-clamp-5"
|
||||
aria-label={localize('com_agents_description_card', {
|
||||
description: agent.description,
|
||||
})}
|
||||
>
|
||||
{agent.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Author */}
|
||||
{displayName && (
|
||||
<div className="mt-1 text-xs text-text-tertiary">
|
||||
<span className="truncate">
|
||||
{localize('com_ui_by_author', { 0: displayName || '' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column: Name, description, and other content */}
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
|
||||
<div className="space-y-1">
|
||||
{/* Agent name */}
|
||||
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||
{agent.name}
|
||||
</Label>
|
||||
|
||||
{/* Agent description */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
{...(agent.description
|
||||
? { 'aria-label': `Description: ${agent.description}` }
|
||||
: {})}
|
||||
>
|
||||
{agent.description ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Owner info */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center text-sm text-text-secondary">
|
||||
<Label>{displayName}</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<AgentDetailContent agent={agent} />
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
192
client/src/components/Agents/AgentDetailContent.tsx
Normal file
192
client/src/components/Agents/AgentDetailContent.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import React from 'react';
|
||||
import { Link, Pin, PinOff } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { OGDialogContent, Button, useToastContext } from '@librechat/client';
|
||||
import {
|
||||
QueryKeys,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
PermissionBits,
|
||||
LocalStorageKeys,
|
||||
AgentListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks';
|
||||
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
interface SupportContact {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AgentWithSupport extends t.Agent {
|
||||
support_contact?: SupportContact;
|
||||
}
|
||||
|
||||
interface AgentDetailContentProps {
|
||||
agent: AgentWithSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog content for displaying agent details
|
||||
* Used inside OGDialog with OGDialogTrigger for proper focus management
|
||||
*/
|
||||
const AgentDetailContent: React.FC<AgentDetailContentProps> = ({ agent }) => {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites();
|
||||
const isFavorite = isFavoriteAgent(agent?.id);
|
||||
|
||||
const handleFavoriteClick = () => {
|
||||
if (agent) {
|
||||
toggleFavoriteAgent(agent.id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to chat with the selected agent
|
||||
*/
|
||||
const handleStartChat = () => {
|
||||
if (agent) {
|
||||
const keys = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }];
|
||||
const listResp = queryClient.getQueryData<AgentListResponse>(keys);
|
||||
if (listResp != null) {
|
||||
if (!listResp.data.some((a) => a.id === agent.id)) {
|
||||
const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))];
|
||||
queryClient.setQueryData<AgentListResponse>(keys, { ...listResp, data: currentAgents });
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
|
||||
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
||||
/** Template with agent configuration */
|
||||
const template = {
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
title: localize('com_agents_chat_with', { name: agent.name || localize('com_ui_agent') }),
|
||||
};
|
||||
|
||||
const currentConvo = getDefaultConversation({
|
||||
conversation: { ...(conversation ?? {}), ...template },
|
||||
preset: template,
|
||||
});
|
||||
|
||||
newConversation({
|
||||
template: currentConvo,
|
||||
preset: template,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the agent's shareable link to clipboard
|
||||
*/
|
||||
const handleCopyLink = () => {
|
||||
const baseUrl = new URL(window.location.origin);
|
||||
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
|
||||
navigator.clipboard
|
||||
.writeText(chatUrl)
|
||||
.then(() => {
|
||||
showToast({
|
||||
message: localize('com_agents_link_copied'),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showToast({
|
||||
message: localize('com_agents_link_copy_failed'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format contact information with mailto links when appropriate
|
||||
*/
|
||||
const formatContact = () => {
|
||||
if (!agent?.support_contact) return null;
|
||||
|
||||
const { name, email } = agent.support_contact;
|
||||
|
||||
if (name && email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||
{/* Agent avatar */}
|
||||
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
|
||||
|
||||
{/* Agent name */}
|
||||
<div className="mt-3 text-center">
|
||||
<h2 className="text-2xl font-bold text-text-primary">
|
||||
{agent?.name || localize('com_agents_loading')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Contact info */}
|
||||
{agent?.support_contact && formatContact() && (
|
||||
<div className="mt-1 text-center text-sm text-text-secondary">
|
||||
{localize('com_agents_contact')}: {formatContact()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent description */}
|
||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||
{agent?.description}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="mb-4 mt-6 flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleFavoriteClick}
|
||||
title={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
>
|
||||
{isFavorite ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyLink}
|
||||
title={localize('com_agents_copy_link')}
|
||||
aria-label={localize('com_agents_copy_link')}
|
||||
>
|
||||
<Link className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
|
||||
{localize('com_agents_start_chat')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentDetailContent;
|
||||
|
|
@ -10,10 +10,10 @@ import ErrorDisplay from './ErrorDisplay';
|
|||
import AgentCard from './AgentCard';
|
||||
|
||||
interface AgentGridProps {
|
||||
category: string; // Currently selected category
|
||||
searchQuery: string; // Current search query
|
||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||
scrollElementRef?: React.RefObject<HTMLElement>; // Parent scroll container ref for infinite scroll
|
||||
category: string;
|
||||
searchQuery: string;
|
||||
onSelectAgent: (agent: t.Agent) => void;
|
||||
scrollElementRef?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -184,7 +184,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
|
|||
{/* Agent grid - 2 per row with proper semantic structure */}
|
||||
{currentAgents && currentAgents.length > 0 && (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2"
|
||||
className="mx-4 grid grid-cols-1 gap-6 md:grid-cols-2"
|
||||
role="grid"
|
||||
aria-label={localize('com_agents_grid_announcement', {
|
||||
count: currentAgents.length,
|
||||
|
|
@ -193,7 +193,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
|
|||
>
|
||||
{currentAgents.map((agent: t.Agent, index: number) => (
|
||||
<div key={`${agent.id}-${index}`} role="gridcell">
|
||||
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
|
||||
<AgentCard agent={agent} onSelect={onSelectAgent} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { SidePanelGroup } from '~/components/SidePanel';
|
|||
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||
import { cn, clearMessagesCache } from '~/utils';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
import AgentDetail from './AgentDetail';
|
||||
import SearchBar from './SearchBar';
|
||||
import AgentGrid from './AgentGrid';
|
||||
import store from '~/store';
|
||||
|
|
@ -45,7 +44,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
|
||||
// Get URL parameters
|
||||
const searchQuery = searchParams.get('q') || '';
|
||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||
|
||||
// Animation state
|
||||
type Direction = 'left' | 'right';
|
||||
|
|
@ -58,10 +56,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
// Ref for the scrollable container to enable infinite scroll
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Local state
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||
|
||||
// Set page title
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
||||
|
||||
|
|
@ -102,28 +96,12 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
}, [category, categoriesQuery.data, displayCategory]);
|
||||
|
||||
/**
|
||||
* Handle agent card selection
|
||||
*
|
||||
* @param agent - The selected agent object
|
||||
* Handle agent card selection - updates URL for deep linking
|
||||
*/
|
||||
const handleAgentSelect = (agent: t.Agent) => {
|
||||
// Update URL with selected agent
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('agent_id', agent.id);
|
||||
setSearchParams(newParams);
|
||||
setSelectedAgent(agent);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle closing the agent detail dialog
|
||||
*/
|
||||
const handleDetailClose = () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('agent_id');
|
||||
setSearchParams(newParams);
|
||||
setSelectedAgent(null);
|
||||
setIsDetailOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -229,11 +207,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
newConversation();
|
||||
};
|
||||
|
||||
// Check if a detail view should be open based on URL
|
||||
useEffect(() => {
|
||||
setIsDetailOpen(!!selectedAgentId);
|
||||
}, [selectedAgentId]);
|
||||
|
||||
// Layout configuration for SidePanelGroup
|
||||
const defaultLayout = useMemo(() => {
|
||||
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
|
||||
|
|
@ -295,7 +268,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
variant="outline"
|
||||
data-testid="agents-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"
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-active-alt max-md:hidden"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon />
|
||||
|
|
@ -512,14 +485,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
|||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
</div>
|
||||
</div>
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
agent={selectedAgent}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleDetailClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
|
|
|
|||
|
|
@ -1,75 +1,20 @@
|
|||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
import { Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { AdminSettingsDialog } from '~/components/ui';
|
||||
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
|
||||
import { useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import type { PermissionConfig } from '~/components/ui';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.USE]: boolean;
|
||||
};
|
||||
|
||||
type LabelControllerProps = {
|
||||
label: string;
|
||||
marketplacePerm: Permissions.USE;
|
||||
control: Control<FormValues, unknown, FormValues>;
|
||||
setValue: UseFormSetValue<FormValues>;
|
||||
getValues: UseFormGetValues<FormValues>;
|
||||
};
|
||||
|
||||
const LabelController: React.FC<LabelControllerProps> = ({
|
||||
control,
|
||||
marketplacePerm,
|
||||
label,
|
||||
getValues,
|
||||
setValue,
|
||||
}) => (
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="cursor-pointer select-none"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue(marketplacePerm, !getValues(marketplacePerm), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<Controller
|
||||
name={marketplacePerm}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const permissions: PermissionConfig[] = [
|
||||
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
|
||||
];
|
||||
|
||||
const MarketplaceAdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdateMarketplacePermissionsMutation({
|
||||
|
||||
const mutation = useUpdateMarketplacePermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
},
|
||||
|
|
@ -78,133 +23,27 @@ const MarketplaceAdminSettings = () => {
|
|||
},
|
||||
});
|
||||
|
||||
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.MARKETPLACE];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE];
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MARKETPLACE];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE]);
|
||||
}
|
||||
}, [roles, selectedRole, reset]);
|
||||
|
||||
if (user?.role !== SystemRoles.ADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelControllerData: {
|
||||
marketplacePerm: Permissions.USE;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
marketplacePerm: Permissions.USE,
|
||||
label: localize('com_ui_marketplace_allow_use'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
mutate({ roleName: selectedRole, updates: data });
|
||||
};
|
||||
|
||||
const roleDropdownItems = [
|
||||
{
|
||||
label: SystemRoles.USER,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.USER);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: SystemRoles.ADMIN,
|
||||
onClick: () => {
|
||||
setSelectedRole(SystemRoles.ADMIN);
|
||||
},
|
||||
},
|
||||
];
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||
aria-label={localize('com_ui_admin_settings')}
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_admin_settings_section', { section: localize('com_ui_marketplace') })}
|
||||
</OGDialogTitle>
|
||||
<div className="p-2">
|
||||
{/* Role selection dropdown */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{localize('com_ui_role_select')}:</span>
|
||||
<DropdownPopup
|
||||
unmountOnHide={true}
|
||||
menuId="role-dropdown"
|
||||
isOpen={isRoleMenuOpen}
|
||||
setIsOpen={setIsRoleMenuOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
|
||||
{selectedRole}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
{/* Permissions form */}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="py-5">
|
||||
{labelControllerData.map(({ marketplacePerm, label }) => (
|
||||
<div key={marketplacePerm}>
|
||||
<LabelController
|
||||
control={control}
|
||||
marketplacePerm={marketplacePerm}
|
||||
label={label}
|
||||
getValues={getValues}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<AdminSettingsDialog
|
||||
permissionType={PermissionTypes.MARKETPLACE}
|
||||
sectionKey="com_ui_marketplace"
|
||||
permissions={permissions}
|
||||
menuId="marketplace-role-dropdown"
|
||||
mutation={mutation}
|
||||
trigger={trigger}
|
||||
dialogContentClassName="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary"
|
||||
showAdminWarning={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,27 @@ jest.mock('~/hooks', () => ({
|
|||
useLocalize: () => mockLocalize,
|
||||
useDebounce: jest.fn(),
|
||||
useAgentCategories: jest.fn(),
|
||||
useDefaultConvo: jest.fn(() => jest.fn(() => ({}))),
|
||||
useFavorites: jest.fn(() => ({
|
||||
isFavoriteAgent: jest.fn(() => false),
|
||||
toggleFavoriteAgent: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Providers
|
||||
jest.mock('~/Providers', () => ({
|
||||
useChatContext: jest.fn(() => ({
|
||||
conversation: null,
|
||||
newConversation: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock @librechat/client toast context
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: jest.fn(() => ({
|
||||
showToast: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider/Agents', () => ({
|
||||
|
|
@ -115,6 +136,13 @@ jest.mock('../SmartLoader', () => ({
|
|||
useHasData: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock AgentDetailContent to avoid testing dialog internals
|
||||
jest.mock('../AgentDetailContent', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line i18next/no-literal-string
|
||||
default: () => <div data-testid="agent-detail-content">Agent Detail Content</div>,
|
||||
}));
|
||||
|
||||
// Import the actual modules to get the mocked functions
|
||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||
import { useAgentCategories, useDebounce } from '~/hooks';
|
||||
|
|
@ -299,7 +327,12 @@ describe('Accessibility Improvements', () => {
|
|||
};
|
||||
|
||||
it('provides comprehensive ARIA labels', () => {
|
||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={jest.fn()} />);
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent as t.Agent} onSelect={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
|
||||
|
|
@ -308,16 +341,19 @@ describe('Accessibility Improvements', () => {
|
|||
});
|
||||
|
||||
it('supports keyboard interaction', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent as t.Agent} onSelect={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.keyDown(card, { key: ' ' });
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
// Card should be keyboard accessible - actual dialog behavior is handled by Radix
|
||||
expect(card).toHaveAttribute('tabIndex', '0');
|
||||
expect(() => fireEvent.keyDown(card, { key: 'Enter' })).not.toThrow();
|
||||
expect(() => fireEvent.keyDown(card, { key: ' ' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
|||
import '@testing-library/jest-dom';
|
||||
import AgentCard from '../AgentCard';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Mock useLocalize hook
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||
|
|
@ -11,25 +12,32 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
|||
com_agents_agent_card_label: '{{name}} agent. {{description}}',
|
||||
com_agents_category_general: 'General',
|
||||
com_agents_category_hr: 'Human Resources',
|
||||
com_ui_by_author: 'by {{0}}',
|
||||
com_agents_description_card: '{{description}}',
|
||||
};
|
||||
return mockTranslations[key] || key;
|
||||
});
|
||||
|
||||
// Mock useAgentCategories hook
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: Record<string, string>) => {
|
||||
useLocalize: () => (key: string, values?: Record<string, string | number>) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_created_by: 'Created by',
|
||||
com_agents_agent_card_label: '{{name}} agent. {{description}}',
|
||||
com_agents_category_general: 'General',
|
||||
com_agents_category_hr: 'Human Resources',
|
||||
com_ui_by_author: 'by {{0}}',
|
||||
com_agents_description_card: '{{description}}',
|
||||
};
|
||||
let translation = mockTranslations[key] || key;
|
||||
|
||||
// Replace placeholders with actual values
|
||||
if (values) {
|
||||
Object.entries(values).forEach(([placeholder, value]) => {
|
||||
translation = translation.replace(new RegExp(`{{${placeholder}}}`, 'g'), value);
|
||||
translation = translation.replace(
|
||||
new RegExp(`\\{\\{${placeholder}\\}\\}`, 'g'),
|
||||
String(value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -42,8 +50,81 @@ jest.mock('~/hooks', () => ({
|
|||
{ value: 'custom', label: 'Custom Category' }, // Non-localized custom category
|
||||
],
|
||||
}),
|
||||
useDefaultConvo: jest.fn(() => jest.fn(() => ({}))),
|
||||
useFavorites: jest.fn(() => ({
|
||||
isFavoriteAgent: jest.fn(() => false),
|
||||
toggleFavoriteAgent: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock AgentDetailContent to avoid testing dialog internals
|
||||
jest.mock('../AgentDetailContent', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line i18next/no-literal-string
|
||||
default: () => <div data-testid="agent-detail-content">Agent Detail Content</div>,
|
||||
}));
|
||||
|
||||
// Mock Providers
|
||||
jest.mock('~/Providers', () => ({
|
||||
useChatContext: jest.fn(() => ({
|
||||
conversation: null,
|
||||
newConversation: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock @librechat/client with proper Dialog behavior
|
||||
jest.mock('@librechat/client', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const React = require('react');
|
||||
return {
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: jest.fn(() => ({
|
||||
showToast: jest.fn(),
|
||||
})),
|
||||
OGDialog: ({ children, open, onOpenChange }: any) => {
|
||||
// Store onOpenChange in context for trigger to call
|
||||
return (
|
||||
<div data-testid="dialog-wrapper" data-open={open}>
|
||||
{React.Children.map(children, (child: any) => {
|
||||
if (child?.type?.displayName === 'OGDialogTrigger' || child?.props?.['data-trigger']) {
|
||||
return React.cloneElement(child, { onOpenChange });
|
||||
}
|
||||
// Only render content when open
|
||||
if (child?.type?.displayName === 'OGDialogContent' && !open) {
|
||||
return null;
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement<any>, {
|
||||
onClick: (e: any) => {
|
||||
(children as any).props?.onClick?.(e);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
|
||||
},
|
||||
OGDialogContent: ({ children }: any) => <div data-testid="dialog-content">{children}</div>,
|
||||
Label: ({ children, className }: any) => <span className={className}>{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Create wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AgentCard', () => {
|
||||
const mockAgent: t.Agent = {
|
||||
id: '1',
|
||||
|
|
@ -69,22 +150,30 @@ describe('AgentCard', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const mockOnClick = jest.fn();
|
||||
const mockOnSelect = jest.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClick.mockClear();
|
||||
mockOnSelect.mockClear();
|
||||
});
|
||||
|
||||
it('renders agent information correctly', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays avatar when provided as object', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const avatarImg = screen.getByAltText('Test Agent avatar');
|
||||
expect(avatarImg).toBeInTheDocument();
|
||||
|
|
@ -97,7 +186,11 @@ describe('AgentCard', () => {
|
|||
avatar: '/string-avatar.png' as any, // Legacy support for string avatars
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithStringAvatar} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithStringAvatar} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const avatarImg = screen.getByAltText('Test Agent avatar');
|
||||
expect(avatarImg).toBeInTheDocument();
|
||||
|
|
@ -110,51 +203,73 @@ describe('AgentCard', () => {
|
|||
avatar: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithoutAvatar as any as t.Agent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check for Feather icon presence by looking for the svg with lucide-feather class
|
||||
const featherIcon = document.querySelector('.lucide-feather');
|
||||
expect(featherIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when card is clicked', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
it('card is clickable and has dialog trigger', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.click(card);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
// Card should be clickable - the actual dialog behavior is handled by Radix
|
||||
expect(card).toBeInTheDocument();
|
||||
expect(() => fireEvent.click(card)).not.toThrow();
|
||||
});
|
||||
|
||||
it('calls onClick when Enter key is pressed', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
it('handles Enter key press', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
// Card should respond to keyboard - the actual dialog behavior is handled by Radix
|
||||
expect(() => fireEvent.keyDown(card, { key: 'Enter' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('calls onClick when Space key is pressed', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
it('handles Space key press', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.keyDown(card, { key: ' ' });
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
// Card should respond to keyboard - the actual dialog behavior is handled by Radix
|
||||
expect(() => fireEvent.keyDown(card, { key: ' ' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not call onClick for other keys', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
it('does not call onSelect for other keys', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.keyDown(card, { key: 'Escape' });
|
||||
|
||||
expect(mockOnClick).not.toHaveBeenCalled();
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies additional className when provided', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} className="custom-class" />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveClass('custom-class');
|
||||
|
|
@ -167,11 +282,14 @@ describe('AgentCard', () => {
|
|||
authorName: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithoutContact} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays authorName when support_contact is missing', () => {
|
||||
|
|
@ -181,54 +299,21 @@ describe('AgentCard', () => {
|
|||
authorName: 'John Doe',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithAuthorName} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays support_contact email when name is missing', () => {
|
||||
const agentWithEmailOnly = {
|
||||
...mockAgent,
|
||||
support_contact: { email: 'contact@example.com' },
|
||||
authorName: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes support_contact name over authorName', () => {
|
||||
const agentWithBoth = {
|
||||
...mockAgent,
|
||||
support_contact: { name: 'Support Team' },
|
||||
authorName: 'John Doe',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes name over email in support_contact', () => {
|
||||
const agentWithNameAndEmail = {
|
||||
...mockAgent,
|
||||
support_contact: {
|
||||
name: 'Support Team',
|
||||
email: 'support@example.com',
|
||||
},
|
||||
authorName: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('by John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveAttribute('tabIndex', '0');
|
||||
|
|
@ -244,7 +329,11 @@ describe('AgentCard', () => {
|
|||
category: 'general',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithCategory} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithCategory} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -255,7 +344,11 @@ describe('AgentCard', () => {
|
|||
category: 'custom',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithCustomCategory} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithCustomCategory} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Custom Category')).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -266,15 +359,35 @@ describe('AgentCard', () => {
|
|||
category: 'unknown',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithUnknownCategory} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={agentWithUnknownCategory} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display category tag when category is not provided', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} onSelect={mockOnSelect} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('General')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('works without onSelect callback', () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentCard agent={mockAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
// Should not throw when clicking without onSelect
|
||||
expect(() => fireEvent.click(card)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ jest.mock('../ErrorDisplay', () => ({
|
|||
// Mock AgentCard component
|
||||
jest.mock('../AgentCard', () => ({
|
||||
__esModule: true,
|
||||
default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => (
|
||||
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
|
||||
default: ({ agent, onSelect }: { agent: t.Agent; onSelect?: (agent: t.Agent) => void }) => (
|
||||
<div data-testid={`agent-card-${agent.id}`} onClick={() => onSelect?.(agent)}>
|
||||
<h3>{agent.name}</h3>
|
||||
<p>{agent.description}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | unde
|
|||
|
||||
const privacyPolicyRender = privacyPolicy?.externalUrl && (
|
||||
<a
|
||||
className="text-sm text-green-600 dark:text-green-500"
|
||||
className="text-sm text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
|
||||
href={privacyPolicy.externalUrl}
|
||||
target={privacyPolicy.openNewTab ? '_blank' : undefined}
|
||||
// Removed for WCAG compliance
|
||||
// target={privacyPolicy.openNewTab ? '_blank' : undefined}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{localize('com_ui_privacy_policy')}
|
||||
|
|
@ -22,9 +23,10 @@ function Footer({ startupConfig }: { startupConfig: TStartupConfig | null | unde
|
|||
|
||||
const termsOfServiceRender = termsOfService?.externalUrl && (
|
||||
<a
|
||||
className="text-sm text-green-600 dark:text-green-500"
|
||||
className="text-sm text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
|
||||
href={termsOfService.externalUrl}
|
||||
target={termsOfService.openNewTab ? '_blank' : undefined}
|
||||
// Removed for WCAG compliance
|
||||
// target={termsOfService.openNewTab ? '_blank' : undefined}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{localize('com_ui_terms_of_service')}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ function Login() {
|
|||
{localize('com_auth_no_account')}{' '}
|
||||
<a
|
||||
href={registerPage()}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
|
||||
>
|
||||
{localize('com_auth_sign_up')}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
{startupConfig.passwordResetEnabled && (
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 underline decoration-transparent transition-all duration-200 hover:text-green-700 hover:decoration-green-700 focus:text-green-700 focus:decoration-green-700 dark:text-green-500 dark:hover:text-green-400 dark:hover:decoration-green-400 dark:focus:text-green-400 dark:focus:decoration-green-400"
|
||||
>
|
||||
{localize('com_auth_password_forgot')}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ test('renders registration form', () => {
|
|||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-commented-out-tests
|
||||
// test('calls registerUser.mutate on registration', async () => {
|
||||
// const mutate = jest.fn();
|
||||
// const { getByTestId, getByRole, history } = setup({
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ const BookmarkEditDialog = ({
|
|||
<OGDialogTemplate
|
||||
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
|
||||
showCloseButton={false}
|
||||
className="w-11/12 md:max-w-2xl"
|
||||
className="w-11/12 md:max-w-lg"
|
||||
main={
|
||||
<BookmarkForm
|
||||
tags={tags}
|
||||
|
|
|
|||
|
|
@ -85,16 +85,11 @@ const BookmarkForm = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
className="mt-6"
|
||||
aria-label="Bookmark form"
|
||||
method="POST"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
|
||||
<form ref={formRef} aria-label="Bookmark form" method="POST" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-4">
|
||||
{/* Tag name input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookmark-tag" className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_bookmarks_title')}
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -118,24 +113,24 @@ const BookmarkForm = ({
|
|||
);
|
||||
},
|
||||
})}
|
||||
className="w-full"
|
||||
aria-invalid={!!errors.tag}
|
||||
placeholder={
|
||||
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
|
||||
}
|
||||
placeholder={localize('com_ui_enter_name')}
|
||||
aria-describedby={errors.tag ? 'bookmark-tag-error' : undefined}
|
||||
/>
|
||||
{errors.tag && (
|
||||
<span id="bookmark-tag-error" role="alert" className="text-sm font-bold text-red-500">
|
||||
<span id="bookmark-tag-error" role="alert" className="text-sm text-red-500">
|
||||
{errors.tag.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid w-full items-center gap-2">
|
||||
{/* Description textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
id="bookmark-description-label"
|
||||
htmlFor="bookmark-description"
|
||||
className="text-left text-sm font-medium"
|
||||
className="text-sm font-medium text-text-primary"
|
||||
>
|
||||
{localize('com_ui_bookmarks_description')}
|
||||
</Label>
|
||||
|
|
@ -151,14 +146,20 @@ const BookmarkForm = ({
|
|||
})}
|
||||
id="bookmark-description"
|
||||
disabled={false}
|
||||
placeholder={localize('com_ui_enter_description')}
|
||||
className={cn(
|
||||
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
|
||||
'min-h-[100px] w-full resize-none rounded-lg border border-border-light',
|
||||
'bg-transparent px-3 py-2 text-sm text-text-primary',
|
||||
'placeholder:text-text-tertiary',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
aria-labelledby="bookmark-description-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add to conversation checkbox */}
|
||||
{conversationId != null && conversationId && (
|
||||
<div className="mt-2 flex w-full items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name="addToConversation"
|
||||
control={control}
|
||||
|
|
@ -167,7 +168,7 @@ const BookmarkForm = ({
|
|||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
className="size-4 cursor-pointer"
|
||||
value={field.value?.toString()}
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
/>
|
||||
|
|
@ -176,16 +177,14 @@ const BookmarkForm = ({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
|
||||
className="form-check-label w-full cursor-pointer text-text-primary"
|
||||
className="cursor-pointer text-sm text-text-primary"
|
||||
onClick={() =>
|
||||
setValue('addToConversation', !(getValues('addToConversation') ?? false), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="flex select-none items-center">
|
||||
{localize('com_ui_bookmarks_add_to_conversation')}
|
||||
</div>
|
||||
{localize('com_ui_bookmarks_add_to_conversation')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function AddMultiConvo() {
|
|||
setAddedConvo({
|
||||
...convo,
|
||||
title: '',
|
||||
});
|
||||
} as TConversation);
|
||||
|
||||
const textarea = document.getElementById(mainTextareaId);
|
||||
if (textarea) {
|
||||
|
|
@ -34,13 +34,12 @@ function AddMultiConvo() {
|
|||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
id="add-multi-conversation-button"
|
||||
aria-label={localize('com_ui_add_multi_conversation')}
|
||||
description={localize('com_ui_add_multi_conversation')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={localize('com_ui_add_multi_conversation')}
|
||||
onClick={clickHandler}
|
||||
data-testid="parameters-button"
|
||||
data-testid="add-multi-convo-button"
|
||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl 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-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import { Constants, buildTree } from 'librechat-data-provider';
|
|||
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 {
|
||||
useResumableStreamToggle,
|
||||
useAddedResponse,
|
||||
useResumeOnLoad,
|
||||
useAdaptiveSSE,
|
||||
useChatHelpers,
|
||||
} from '~/hooks';
|
||||
import ConversationStarters from './Input/ConversationStarters';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
|
|
@ -32,7 +38,6 @@ function LoadingSpinner() {
|
|||
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();
|
||||
|
|
@ -49,10 +54,18 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
});
|
||||
|
||||
const chatHelpers = useChatHelpers(index, conversationId);
|
||||
const addedChatHelpers = useAddedResponse({ rootIndex: index });
|
||||
const addedChatHelpers = useAddedResponse();
|
||||
|
||||
useSSE(rootSubmission, chatHelpers, false);
|
||||
useSSE(addedSubmission, addedChatHelpers, true);
|
||||
useResumableStreamToggle(
|
||||
chatHelpers.conversation?.endpoint,
|
||||
chatHelpers.conversation?.endpointType,
|
||||
);
|
||||
|
||||
useAdaptiveSSE(rootSubmission, chatHelpers, false, index);
|
||||
|
||||
// Auto-resume if navigating back to conversation with active job
|
||||
// Wait for messages to load before resuming to avoid race condition
|
||||
useResumeOnLoad(conversationId, chatHelpers.getMessages, index, !isLoading);
|
||||
|
||||
const methods = useForm<ChatFormValues>({
|
||||
defaultValues: { text: '' },
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default function ExportAndShareMenu({
|
|||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl 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"
|
||||
>
|
||||
<Share2
|
||||
className="icon-md text-text-secondary"
|
||||
className="icon-lg text-text-primary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ export default function Header() {
|
|||
{!navVisible && (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'auto', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
key="header-buttons"
|
||||
>
|
||||
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { TConversation, TEndpointOption, TPreset } from 'librechat-data-provider';
|
||||
import { isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { EndpointIcon } from '~/components/Endpoints';
|
||||
import { getPresetTitle } from '~/utils';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
||||
export default function AddedConvo({
|
||||
addedConvo,
|
||||
|
|
@ -13,13 +13,23 @@ export default function AddedConvo({
|
|||
addedConvo: TConversation | null;
|
||||
setAddedConvo: SetterOrUpdater<TConversation | null>;
|
||||
}) {
|
||||
const getSender = useGetSender();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const title = useMemo(() => {
|
||||
const sender = getSender(addedConvo as TEndpointOption);
|
||||
const title = getPresetTitle(addedConvo as TPreset);
|
||||
return `+ ${sender}: ${title}`;
|
||||
}, [addedConvo, getSender]);
|
||||
// Priority: agent name > modelDisplayLabel > modelLabel > model
|
||||
if (isAgentsEndpoint(addedConvo?.endpoint) && addedConvo?.agent_id) {
|
||||
const agent = agentsMap?.[addedConvo.agent_id];
|
||||
if (agent?.name) {
|
||||
return `+ ${agent.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
const endpointConfig = endpointsConfig?.[addedConvo?.endpoint ?? ''];
|
||||
const displayLabel =
|
||||
endpointConfig?.modelDisplayLabel || addedConvo?.modelLabel || addedConvo?.model || 'AI';
|
||||
|
||||
return `+ ${displayLabel}`;
|
||||
}, [addedConvo, agentsMap, endpointsConfig]);
|
||||
|
||||
if (!addedConvo) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@ function Artifacts() {
|
|||
'ml-1 h-4 w-4 text-text-secondary transition-transform duration-300 md:ml-0.5',
|
||||
isButtonExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true" />
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Ariakit.MenuButton>
|
||||
|
||||
<Ariakit.Menu
|
||||
|
|
|
|||
|
|
@ -78,14 +78,11 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
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,
|
||||
|
|
@ -131,7 +128,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
setFiles,
|
||||
textAreaRef,
|
||||
conversationId,
|
||||
isSubmitting: isSubmitting || isSubmittingAdded,
|
||||
isSubmitting,
|
||||
});
|
||||
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||
|
|
@ -327,7 +324,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
</div>
|
||||
<BadgeRow
|
||||
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
|
||||
isSubmitting={isSubmitting || isSubmittingAdded}
|
||||
isSubmitting={isSubmitting}
|
||||
conversationId={conversationId}
|
||||
onChange={setBadges}
|
||||
isInChat={
|
||||
|
|
@ -346,7 +343,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
/>
|
||||
)}
|
||||
<div className={`${isRTL ? 'ml-2' : 'mr-2'}`}>
|
||||
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
|
||||
{isSubmitting && showStopButton ? (
|
||||
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
|
||||
) : (
|
||||
endpoint && (
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
|
|||
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',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||
)}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (!inputRef.current) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
getEndpointFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useGetFileConfig, useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import AttachFile from './AttachFile';
|
||||
|
||||
|
|
@ -26,6 +27,28 @@ function AttachFileChat({
|
|||
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
|
||||
const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]);
|
||||
|
||||
const agentsMap = useAgentsMapContext();
|
||||
|
||||
const needsAgentFetch = useMemo(() => {
|
||||
if (!isAgents || !conversation?.agent_id) {
|
||||
return false;
|
||||
}
|
||||
const agent = agentsMap?.[conversation.agent_id];
|
||||
return !agent?.model_parameters;
|
||||
}, [isAgents, conversation?.agent_id, agentsMap]);
|
||||
|
||||
const { data: agentData } = useGetAgentByIdQuery(conversation?.agent_id, {
|
||||
enabled: needsAgentFetch,
|
||||
});
|
||||
|
||||
const useResponsesApi = useMemo(() => {
|
||||
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
|
||||
return conversation?.useResponsesApi;
|
||||
}
|
||||
const agent = agentData || agentsMap?.[conversation.agent_id];
|
||||
return agent?.model_parameters?.useResponsesApi;
|
||||
}, [isAgents, conversation?.agent_id, conversation?.useResponsesApi, agentData, agentsMap]);
|
||||
|
||||
const { data: fileConfig = null } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
|
@ -68,6 +91,7 @@ function AttachFileChat({
|
|||
conversationId={conversationId}
|
||||
agentId={conversation?.agent_id}
|
||||
endpointFileConfig={endpointFileConfig}
|
||||
useResponsesApi={useResponsesApi}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
TerminalSquareIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Providers,
|
||||
EToolResources,
|
||||
EModelEndpoint,
|
||||
defaultAgentCapabilities,
|
||||
|
|
@ -36,6 +37,8 @@ import { ephemeralAgentByConvoId } from '~/store';
|
|||
import { MenuItemProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type FileUploadType = 'image' | 'document' | 'image_document' | 'image_document_video_audio';
|
||||
|
||||
interface AttachFileMenuProps {
|
||||
agentId?: string | null;
|
||||
endpoint?: string | null;
|
||||
|
|
@ -43,6 +46,7 @@ interface AttachFileMenuProps {
|
|||
conversationId: string;
|
||||
endpointType?: EModelEndpoint;
|
||||
endpointFileConfig?: EndpointFileConfig;
|
||||
useResponsesApi?: boolean;
|
||||
}
|
||||
|
||||
const AttachFileMenu = ({
|
||||
|
|
@ -52,6 +56,7 @@ const AttachFileMenu = ({
|
|||
endpointType,
|
||||
conversationId,
|
||||
endpointFileConfig,
|
||||
useResponsesApi,
|
||||
}: AttachFileMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
|
|
@ -83,9 +88,7 @@ const AttachFileMenu = ({
|
|||
ephemeralAgent,
|
||||
);
|
||||
|
||||
const handleUploadClick = (
|
||||
fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal',
|
||||
) => {
|
||||
const handleUploadClick = (fileType?: FileUploadType) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -94,9 +97,9 @@ const AttachFileMenu = ({
|
|||
inputRef.current.accept = 'image/*';
|
||||
} else if (fileType === 'document') {
|
||||
inputRef.current.accept = '.pdf,application/pdf';
|
||||
} else if (fileType === 'multimodal') {
|
||||
} else if (fileType === 'image_document') {
|
||||
inputRef.current.accept = 'image/*,.pdf,application/pdf';
|
||||
} else if (fileType === 'google_multimodal') {
|
||||
} else if (fileType === 'image_document_video_audio') {
|
||||
inputRef.current.accept = 'image/*,.pdf,application/pdf,video/*,audio/*';
|
||||
} else {
|
||||
inputRef.current.accept = '';
|
||||
|
|
@ -106,23 +109,33 @@ const AttachFileMenu = ({
|
|||
};
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const createMenuItems = (
|
||||
onAction: (fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal') => void,
|
||||
) => {
|
||||
const createMenuItems = (onAction: (fileType?: FileUploadType) => void) => {
|
||||
const items: MenuItemProps[] = [];
|
||||
|
||||
const currentProvider = provider || endpoint;
|
||||
let currentProvider = provider || endpoint;
|
||||
|
||||
// This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive
|
||||
if (currentProvider?.toLowerCase() === Providers.OPENROUTER) {
|
||||
currentProvider = Providers.OPENROUTER;
|
||||
}
|
||||
|
||||
const isAzureWithResponsesApi =
|
||||
currentProvider === EModelEndpoint.azureOpenAI && useResponsesApi;
|
||||
|
||||
if (
|
||||
isDocumentSupportedProvider(endpointType) ||
|
||||
isDocumentSupportedProvider(currentProvider)
|
||||
isDocumentSupportedProvider(currentProvider) ||
|
||||
isAzureWithResponsesApi
|
||||
) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_provider'),
|
||||
onClick: () => {
|
||||
setToolResource(undefined);
|
||||
onAction(
|
||||
(provider || endpoint) === EModelEndpoint.google ? 'google_multimodal' : 'multimodal',
|
||||
);
|
||||
let fileType: Exclude<FileUploadType, 'image' | 'document'> = 'image_document';
|
||||
if (currentProvider === Providers.GOOGLE || currentProvider === Providers.OPENROUTER) {
|
||||
fileType = 'image_document_video_audio';
|
||||
}
|
||||
onAction(fileType);
|
||||
},
|
||||
icon: <FileImageIcon className="icon-md" />,
|
||||
});
|
||||
|
|
@ -204,6 +217,7 @@ const AttachFileMenu = ({
|
|||
provider,
|
||||
endpointType,
|
||||
capabilities,
|
||||
useResponsesApi,
|
||||
setToolResource,
|
||||
setEphemeralAgent,
|
||||
sharePointEnabled,
|
||||
|
|
@ -220,7 +234,7 @@ const AttachFileMenu = ({
|
|||
id="attach-file-menu-button"
|
||||
aria-label="Attach File Options"
|
||||
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',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import {
|
||||
Providers,
|
||||
inferMimeType,
|
||||
EToolResources,
|
||||
EModelEndpoint,
|
||||
|
|
@ -46,7 +47,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
* Use definition for agents endpoint for ephemeral agents
|
||||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
const { conversationId, agentId, endpoint, endpointType } = useDragDropContext();
|
||||
const { conversationId, agentId, endpoint, endpointType, useResponsesApi } = useDragDropContext();
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? ''));
|
||||
const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions(
|
||||
agentId,
|
||||
|
|
@ -55,15 +56,28 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
|
||||
const options = useMemo(() => {
|
||||
const _options: FileOption[] = [];
|
||||
const currentProvider = provider || endpoint;
|
||||
let currentProvider = provider || endpoint;
|
||||
|
||||
// This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive
|
||||
if (currentProvider?.toLowerCase() === Providers.OPENROUTER) {
|
||||
currentProvider = Providers.OPENROUTER;
|
||||
}
|
||||
|
||||
/** Helper to get inferred MIME type for a file */
|
||||
const getFileType = (file: File) => inferMimeType(file.name, file.type);
|
||||
|
||||
const isAzureWithResponsesApi =
|
||||
currentProvider === EModelEndpoint.azureOpenAI && useResponsesApi;
|
||||
|
||||
// Check if provider supports document upload
|
||||
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
|
||||
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
||||
const validFileTypes = isGoogleProvider
|
||||
if (
|
||||
isDocumentSupportedProvider(endpointType) ||
|
||||
isDocumentSupportedProvider(currentProvider) ||
|
||||
isAzureWithResponsesApi
|
||||
) {
|
||||
const supportsImageDocVideoAudio =
|
||||
currentProvider === EModelEndpoint.google || currentProvider === Providers.OPENROUTER;
|
||||
const validFileTypes = supportsImageDocVideoAudio
|
||||
? files.every((file) => {
|
||||
const type = getFileType(file);
|
||||
return (
|
||||
|
|
@ -123,6 +137,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
endpoint,
|
||||
endpointType,
|
||||
capabilities,
|
||||
useResponsesApi,
|
||||
codeAllowedByAgent,
|
||||
fileSearchAllowedByAgent,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,15 @@ import { useGetFiles } from '~/data-provider';
|
|||
import { DataTable, columns } from './Table';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function Files({ open, onOpenChange }) {
|
||||
export function MyFilesModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerRef,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triggerRef?: React.RefObject<HTMLButtonElement | HTMLDivElement | null>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const { data: files = [] } = useGetFiles<TFile[]>({
|
||||
|
|
@ -18,7 +26,7 @@ export default function Files({ open, onOpenChange }) {
|
|||
});
|
||||
|
||||
return (
|
||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_my_files')}
|
||||
className="w-11/12 bg-background text-text-primary shadow-2xl"
|
||||
|
|
@ -92,7 +92,8 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_name_sort')} aria-hidden="true"
|
||||
aria-label={localize('com_ui_name_sort')}
|
||||
aria-hidden="true"
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
|
|
@ -150,7 +151,8 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_date_sort')} aria-hidden="true"
|
||||
aria-label={localize('com_ui_date_sort')}
|
||||
aria-hidden="true"
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
|
|
@ -268,7 +270,8 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_size_sort')} aria-hidden="true"
|
||||
aria-label={localize('com_ui_size_sort')}
|
||||
aria-hidden="true"
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_size')}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
|
|
@ -17,7 +16,6 @@ import type {
|
|||
} from '@tanstack/react-table';
|
||||
import { FileContext } from 'librechat-data-provider';
|
||||
import {
|
||||
Input,
|
||||
Table,
|
||||
Button,
|
||||
Spinner,
|
||||
|
|
@ -26,6 +24,7 @@ import {
|
|||
TableCell,
|
||||
TableHead,
|
||||
TrashIcon,
|
||||
FilterInput,
|
||||
TableHeader,
|
||||
useMediaQuery,
|
||||
} from '@librechat/client';
|
||||
|
|
@ -115,23 +114,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
)}
|
||||
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-text-secondary" />
|
||||
<Input
|
||||
id="files-filter"
|
||||
placeholder=" "
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="peer w-full pl-10 text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={localize('com_files_filter_input')}
|
||||
/>
|
||||
<label
|
||||
htmlFor="files-filter"
|
||||
className="pointer-events-none absolute left-10 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{localize('com_files_filter')}
|
||||
</label>
|
||||
</div>
|
||||
<FilterInput
|
||||
inputId="files-filter"
|
||||
label={localize('com_files_filter')}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<div className="relative focus-within:z-[100]">
|
||||
<ColumnVisibilityDropdown
|
||||
table={table}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ export function SortFilterHeader<TData, TValue>({
|
|||
{
|
||||
label: localize('com_ui_ascending'),
|
||||
onClick: () => column.toggleSorting(false),
|
||||
icon: <ArrowUpIcon className="h-3.5 w-3.5 text-muted-foreground/70" />,
|
||||
icon: <ArrowUpIcon className="icon-sm text-text-secondary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_descending'),
|
||||
onClick: () => column.toggleSorting(true),
|
||||
icon: <ArrowDownIcon className="h-3.5 w-3.5 text-muted-foreground/70" />,
|
||||
icon: <ArrowDownIcon className="icon-sm text-text-secondary" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -56,9 +56,7 @@ export function SortFilterHeader<TData, TValue>({
|
|||
items.push({
|
||||
label: filterValue,
|
||||
onClick: () => column.setFilterValue(value),
|
||||
icon: (
|
||||
<ListFilter className="h-3.5 w-3.5 text-muted-foreground/70" aria-hidden="true" />
|
||||
),
|
||||
icon: <ListFilter className="icon-sm text-text-secondary" aria-hidden="true" />,
|
||||
show: true,
|
||||
className: isActive ? 'border-l-2 border-l-border-xheavy' : '',
|
||||
});
|
||||
|
|
@ -70,7 +68,7 @@ export function SortFilterHeader<TData, TValue>({
|
|||
items.push({
|
||||
label: localize('com_ui_show_all'),
|
||||
onClick: () => column.setFilterValue(undefined),
|
||||
icon: <FilterX className="h-3.5 w-3.5 text-muted-foreground/70" />,
|
||||
icon: <FilterX className="icon-sm text-text-secondary" />,
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -113,9 +111,9 @@ export function SortFilterHeader<TData, TValue>({
|
|||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsFiltered() ? (
|
||||
<ListFilter className="icon-sm text-muted-foreground/70" aria-hidden="true" />
|
||||
<ListFilter className="icon-sm" aria-hidden="true" />
|
||||
) : (
|
||||
<ListFilter className="icon-sm opacity-30" aria-hidden="true" />
|
||||
<ListFilter className="icon-sm text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
{(() => {
|
||||
const sortState = column.getIsSorted();
|
||||
|
|
|
|||
|
|
@ -278,7 +278,6 @@ describe('AttachFileMenu', () => {
|
|||
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
|
||||
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
|
||||
{ name: 'Google', endpoint: EModelEndpoint.google },
|
||||
{ name: 'Azure OpenAI', endpoint: EModelEndpoint.azureOpenAI },
|
||||
{ name: 'Custom', endpoint: EModelEndpoint.custom },
|
||||
];
|
||||
|
||||
|
|
@ -301,6 +300,45 @@ describe('AttachFileMenu', () => {
|
|||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.azureOpenAI,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.azureOpenAI,
|
||||
endpointType: EModelEndpoint.azureOpenAI,
|
||||
useResponsesApi: true,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
provider: EModelEndpoint.azureOpenAI,
|
||||
});
|
||||
|
||||
renderAttachFileMenu({
|
||||
endpoint: EModelEndpoint.azureOpenAI,
|
||||
endpointType: EModelEndpoint.azureOpenAI,
|
||||
useResponsesApi: false,
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Capabilities', () => {
|
||||
|
|
@ -512,7 +550,7 @@ describe('AttachFileMenu', () => {
|
|||
});
|
||||
|
||||
describe('Google Provider Special Case', () => {
|
||||
it('should use google_multimodal file type for Google provider', () => {
|
||||
it('should use image_document_video_audio file type for Google provider', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
|
|
@ -536,7 +574,7 @@ describe('AttachFileMenu', () => {
|
|||
// The file input should have been clicked (indirectly tested through the implementation)
|
||||
});
|
||||
|
||||
it('should use multimodal file type for non-Google providers', () => {
|
||||
it('should use image_document file type for non-Google providers', () => {
|
||||
mockUseAgentToolPermissions.mockReturnValue({
|
||||
fileSearchAllowedByAgent: false,
|
||||
codeAllowedByAgent: false,
|
||||
|
|
@ -555,7 +593,7 @@ describe('AttachFileMenu', () => {
|
|||
expect(uploadProviderButton).toBeInTheDocument();
|
||||
fireEvent.click(uploadProviderButton);
|
||||
|
||||
// Implementation detail - multimodal type is used
|
||||
// Implementation detail - image_document type is used
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ describe('DragDropModal - Provider Detection', () => {
|
|||
{ name: 'OpenAI', value: EModelEndpoint.openAI },
|
||||
{ name: 'Anthropic', value: EModelEndpoint.anthropic },
|
||||
{ name: 'Google', value: EModelEndpoint.google },
|
||||
{ name: 'Azure OpenAI', value: EModelEndpoint.azureOpenAI },
|
||||
{ name: 'Custom', value: EModelEndpoint.custom },
|
||||
];
|
||||
|
||||
|
|
@ -72,6 +71,10 @@ describe('DragDropModal - Provider Detection', () => {
|
|||
expect(isDocumentSupportedProvider(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT recognize Azure OpenAI as supported (requires useResponsesApi)', () => {
|
||||
expect(isDocumentSupportedProvider(EModelEndpoint.azureOpenAI)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useMemo, useCallback, useRef } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { MultiSelect, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import StackedMCPIcons from '~/components/MCP/StackedMCPIcons';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -13,96 +16,117 @@ function MCPSelectContent() {
|
|||
localize,
|
||||
isPinned,
|
||||
mcpValues,
|
||||
isInitializing,
|
||||
placeholderText,
|
||||
batchToggleServers,
|
||||
getConfigDialogProps,
|
||||
getServerStatusIconProps,
|
||||
selectableServers,
|
||||
connectionStatus,
|
||||
isInitializing,
|
||||
getConfigDialogProps,
|
||||
toggleServerSelection,
|
||||
getServerStatusIconProps,
|
||||
} = mcpServerManager;
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(
|
||||
values: string[],
|
||||
placeholder?: string,
|
||||
items?: (string | { label: string; value: string })[],
|
||||
) => {
|
||||
if (values.length === 0) {
|
||||
return placeholder || localize('com_ui_select_placeholder');
|
||||
}
|
||||
if (values.length === 1) {
|
||||
const selectedItem = items?.find((i) => typeof i !== 'string' && i.value == values[0]);
|
||||
return selectedItem && typeof selectedItem !== 'string' ? selectedItem.label : values[0];
|
||||
}
|
||||
return localize('com_ui_x_selected', { 0: values.length });
|
||||
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
|
||||
const isOpen = menuStore.useState('open');
|
||||
const focusedElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const selectedCount = mcpValues?.length ?? 0;
|
||||
|
||||
// Wrap toggleServerSelection to preserve focus after state update
|
||||
const handleToggle = useCallback(
|
||||
(serverName: string) => {
|
||||
// Save currently focused element
|
||||
focusedElementRef.current = document.activeElement as HTMLElement;
|
||||
toggleServerSelection(serverName);
|
||||
// Restore focus after React re-renders
|
||||
requestAnimationFrame(() => {
|
||||
focusedElementRef.current?.focus();
|
||||
});
|
||||
},
|
||||
[localize],
|
||||
[toggleServerSelection],
|
||||
);
|
||||
|
||||
const renderItemContent = useCallback(
|
||||
(serverName: string, defaultContent: React.ReactNode) => {
|
||||
const statusIconProps = getServerStatusIconProps(serverName);
|
||||
const isServerInitializing = isInitializing(serverName);
|
||||
const selectedServers = useMemo(() => {
|
||||
if (!mcpValues || mcpValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return selectableServers.filter((s) => mcpValues.includes(s.serverName));
|
||||
}, [selectableServers, mcpValues]);
|
||||
|
||||
/**
|
||||
Common wrapper for the main content (check mark + text).
|
||||
Ensures Check & Text are adjacent and the group takes available space.
|
||||
*/
|
||||
const mainContentWrapper = (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none ${
|
||||
isServerInitializing ? 'opacity-50' : ''
|
||||
}`}
|
||||
tabIndex={0}
|
||||
disabled={isServerInitializing}
|
||||
>
|
||||
{defaultContent}
|
||||
</button>
|
||||
);
|
||||
|
||||
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
||||
|
||||
if (statusIcon) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{mainContentWrapper}
|
||||
<div className="ml-2 flex items-center">{statusIcon}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return mainContentWrapper;
|
||||
},
|
||||
[getServerStatusIconProps, isInitializing],
|
||||
);
|
||||
const displayText = useMemo(() => {
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedCount === 1) {
|
||||
const server = selectableServers.find((s) => s.serverName === mcpValues?.[0]);
|
||||
return server?.config?.title || mcpValues?.[0];
|
||||
}
|
||||
return localize('com_ui_x_selected', { 0: selectedCount });
|
||||
}, [selectedCount, selectableServers, mcpValues, localize]);
|
||||
|
||||
if (!isPinned && mcpValues?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configDialogProps = getConfigDialogProps();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
items={selectableServers.map((s) => ({
|
||||
label: s.config.title || s.serverName,
|
||||
value: s.serverName,
|
||||
}))}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={batchToggleServers}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
renderItemContent={renderItemContent}
|
||||
placeholder={placeholderText}
|
||||
popoverClassName="min-w-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName={cn(
|
||||
'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all',
|
||||
'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
)}
|
||||
/>
|
||||
<Ariakit.MenuProvider store={menuStore}>
|
||||
<TooltipAnchor
|
||||
description={placeholderText}
|
||||
disabled={isOpen}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
className={cn(
|
||||
'group relative inline-flex items-center justify-center gap-1.5',
|
||||
'border border-border-medium text-sm font-medium transition-all',
|
||||
'h-9 min-w-9 rounded-full bg-transparent px-2.5 shadow-sm',
|
||||
'hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
'md:w-fit md:justify-start md:px-3',
|
||||
isOpen && 'bg-surface-hover',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StackedMCPIcons selectedServers={selectedServers} maxIcons={3} iconSize="sm" />
|
||||
<span className="hidden truncate text-text-primary md:block">
|
||||
{displayText || placeholderText}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'hidden h-3 w-3 text-text-secondary transition-transform md:block',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</TooltipAnchor>
|
||||
|
||||
<Ariakit.Menu
|
||||
portal={true}
|
||||
gutter={8}
|
||||
aria-label={localize('com_ui_mcp_servers')}
|
||||
className={cn(
|
||||
'z-50 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
|
||||
'border border-border-light bg-presentation p-1.5 shadow-lg',
|
||||
'origin-top opacity-0 transition-[opacity,transform] duration-200 ease-out',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'scale-95 data-[leave]:scale-95 data-[leave]:opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
|
||||
{selectableServers.map((server) => (
|
||||
<MCPServerMenuItem
|
||||
key={server.serverName}
|
||||
server={server}
|
||||
isSelected={mcpValues?.includes(server.serverName) ?? false}
|
||||
connectionStatus={connectionStatus}
|
||||
isInitializing={isInitializing}
|
||||
statusIconProps={getServerStatusIconProps(server.serverName)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.Menu>
|
||||
</Ariakit.MenuProvider>
|
||||
{configDialogProps && (
|
||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { PinIcon, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { MCPIcon, PinIcon } from '@librechat/client';
|
||||
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPSubMenuProps {
|
||||
|
|
@ -13,14 +14,16 @@ interface MCPSubMenuProps {
|
|||
|
||||
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
({ placeholder, ...props }, ref) => {
|
||||
const localize = useLocalize();
|
||||
const { mcpServerManager } = useBadgeRowContext();
|
||||
const {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
isInitializing,
|
||||
placeholderText,
|
||||
availableMCPServers,
|
||||
selectableServers,
|
||||
connectionStatus,
|
||||
isInitializing,
|
||||
getConfigDialogProps,
|
||||
toggleServerSelection,
|
||||
getServerStatusIconProps,
|
||||
|
|
@ -33,7 +36,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
});
|
||||
|
||||
// Don't render if no MCP servers are configured
|
||||
if (!availableMCPServers || availableMCPServers.length === 0) {
|
||||
if (!selectableServers || selectableServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
<Ariakit.MenuProvider store={menuStore}>
|
||||
<Ariakit.MenuItem
|
||||
{...props}
|
||||
hideOnClick={false}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
@ -55,9 +59,9 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MCPIcon className="icon-md" />
|
||||
<MCPIcon className="h-5 w-5 flex-shrink-0 text-text-primary" aria-hidden="true" />
|
||||
<span>{placeholder || placeholderText}</span>
|
||||
<ChevronRight className="ml-auto h-3 w-3" />
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -70,55 +74,36 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
'hover:bg-surface-tertiary hover:shadow-sm',
|
||||
!isPinned && 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isPinned ? 'Unpin' : 'Pin'}
|
||||
aria-label={isPinned ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isPinned} />
|
||||
</div>
|
||||
</button>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
<Ariakit.Menu
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
aria-label={localize('com_ui_mcp_servers')}
|
||||
className={cn(
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary p-1 shadow-lg',
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
|
||||
'border border-border-light bg-presentation p-1.5 shadow-lg',
|
||||
)}
|
||||
>
|
||||
{availableMCPServers.map((s) => {
|
||||
const statusIconProps = getServerStatusIconProps(s.serverName);
|
||||
const isSelected = mcpValues?.includes(s.serverName) ?? false;
|
||||
const isServerInitializing = isInitializing(s.serverName);
|
||||
|
||||
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
||||
|
||||
return (
|
||||
<Ariakit.MenuItem
|
||||
key={s.serverName}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleServerSelection(s.serverName);
|
||||
}}
|
||||
disabled={isServerInitializing}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 justify-between text-sm',
|
||||
isServerInitializing &&
|
||||
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
|
||||
isSelected && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-grow items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isSelected} />
|
||||
<span>{s.config.title || s.serverName}</span>
|
||||
</div>
|
||||
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
||||
</Ariakit.MenuItem>
|
||||
);
|
||||
})}
|
||||
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
|
||||
{selectableServers.map((server) => (
|
||||
<MCPServerMenuItem
|
||||
key={server.serverName}
|
||||
server={server}
|
||||
isSelected={mcpValues?.includes(server.serverName) ?? false}
|
||||
connectionStatus={connectionStatus}
|
||||
isInitializing={isInitializing}
|
||||
statusIconProps={getServerStatusIconProps(server.serverName)}
|
||||
onToggle={toggleServerSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.Menu>
|
||||
</Ariakit.MenuProvider>
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default function StreamAudio({ index = 0 }) {
|
|||
const { pauseGlobalAudio } = usePauseGlobalAudio();
|
||||
|
||||
const { conversationId: paramId } = useParams();
|
||||
const queryParam = paramId === 'new' ? paramId : latestMessage?.conversationId ?? paramId ?? '';
|
||||
const queryParam = paramId === 'new' ? paramId : (latestMessage?.conversationId ?? paramId ?? '');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const getMessages = useCallback(
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
id="tools-dropdown-button"
|
||||
aria-label="Tools Options"
|
||||
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',
|
||||
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
|
||||
isPopoverActive && 'bg-surface-hover',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -147,9 +147,9 @@ const BookmarkMenu: FC = () => {
|
|||
return <Spinner aria-label="Spinner" />;
|
||||
}
|
||||
if ((tags?.length ?? 0) > 0) {
|
||||
return <BookmarkFilledIcon className="icon-sm" aria-label="Filled Bookmark" />;
|
||||
return <BookmarkFilledIcon className="icon-lg" aria-label="Filled Bookmark" />;
|
||||
}
|
||||
return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />;
|
||||
return <BookmarkIcon className="icon-lg" aria-label="Bookmark" />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
!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',
|
||||
menuStore.useState('open')
|
||||
? 'bg-surface-tertiary hover:bg-surface-tertiary'
|
||||
: 'bg-surface-secondary hover:bg-surface-tertiary',
|
||||
? 'bg-surface-active-alt hover:bg-surface-active-alt'
|
||||
: 'bg-presentation hover:bg-surface-active-alt',
|
||||
props.className,
|
||||
)}
|
||||
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
|
||||
|
|
@ -66,7 +66,7 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
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',
|
||||
'bg-presentation 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',
|
||||
)}
|
||||
|
|
@ -80,13 +80,13 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
|
|||
autoSelect
|
||||
render={combobox}
|
||||
className={cn(
|
||||
'peer mt-1 h-10 w-full rounded-lg border-none bg-transparent px-2 text-base',
|
||||
'peer flex h-10 w-full items-center justify-center 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',
|
||||
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary',
|
||||
)}
|
||||
/>
|
||||
{comboboxLabel && (
|
||||
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-surface-secondary peer-[:not(:placeholder-shown)]:text-xs">
|
||||
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-presentation peer-[:not(:placeholder-shown)]:text-xs sm:top-1.5">
|
||||
{comboboxLabel}
|
||||
</label>
|
||||
)}
|
||||
|
|
@ -168,7 +168,7 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
|
|||
blurOnHoverEnd: false,
|
||||
...props,
|
||||
className: cn(
|
||||
'relative 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 before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
|
||||
'relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1 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:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
|
||||
props.className,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function ModelSelectorContent() {
|
|||
description={localize('com_ui_select_model')}
|
||||
render={
|
||||
<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"
|
||||
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
|
||||
aria-label={localize('com_ui_select_model')}
|
||||
>
|
||||
{selectedIcon && React.isValidElement(selectedIcon) && (
|
||||
|
|
|
|||
|
|
@ -104,10 +104,18 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
|||
});
|
||||
|
||||
// State
|
||||
const [selectedValues, setSelectedValues] = useState<SelectedValues>({
|
||||
endpoint: endpoint || '',
|
||||
model: model || '',
|
||||
modelSpec: spec || '',
|
||||
const [selectedValues, setSelectedValues] = useState<SelectedValues>(() => {
|
||||
let initialModel = model || '';
|
||||
if (isAgentsEndpoint(endpoint) && agent_id) {
|
||||
initialModel = agent_id;
|
||||
} else if (isAssistantsEndpoint(endpoint) && assistant_id) {
|
||||
initialModel = assistant_id;
|
||||
}
|
||||
return {
|
||||
endpoint: endpoint || '',
|
||||
model: initialModel,
|
||||
modelSpec: spec || '',
|
||||
};
|
||||
});
|
||||
useSelectorEffects({
|
||||
agentsMap,
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ import type { TModelSpec } from 'librechat-data-provider';
|
|||
import { CustomMenu as Menu } from '../CustomMenu';
|
||||
import { ModelSpecItem } from './ModelSpecItem';
|
||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||
import GroupIcon from './GroupIcon';
|
||||
|
||||
interface CustomGroupProps {
|
||||
groupName: string;
|
||||
specs: TModelSpec[];
|
||||
groupIcon?: string;
|
||||
}
|
||||
|
||||
export function CustomGroup({ groupName, specs }: CustomGroupProps) {
|
||||
export function CustomGroup({ groupName, specs, groupIcon }: CustomGroupProps) {
|
||||
const { selectedValues } = useModelSelectorContext();
|
||||
const { modelSpec: selectedSpec } = selectedValues;
|
||||
|
||||
|
|
@ -25,6 +27,11 @@ export function CustomGroup({ groupName, specs }: CustomGroupProps) {
|
|||
label={
|
||||
<div className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{groupIcon && (
|
||||
<div className="flex-shrink-0">
|
||||
<GroupIcon iconURL={groupIcon} groupName={groupName} />
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate text-left">{groupName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -45,22 +52,27 @@ export function renderCustomGroups(
|
|||
const endpointValues = new Set(mappedEndpoints.map((ep) => ep.value));
|
||||
|
||||
// Group specs by their group field (excluding endpoint-matched groups and ungrouped)
|
||||
// Also track the groupIcon for each group (first spec with groupIcon wins)
|
||||
const customGroups = modelSpecs.reduce(
|
||||
(acc, spec) => {
|
||||
if (!spec.group || endpointValues.has(spec.group)) {
|
||||
return acc;
|
||||
}
|
||||
if (!acc[spec.group]) {
|
||||
acc[spec.group] = [];
|
||||
acc[spec.group] = { specs: [], groupIcon: undefined };
|
||||
}
|
||||
acc[spec.group].specs.push(spec);
|
||||
// Use the first groupIcon found for the group
|
||||
if (!acc[spec.group].groupIcon && spec.groupIcon) {
|
||||
acc[spec.group].groupIcon = spec.groupIcon;
|
||||
}
|
||||
acc[spec.group].push(spec);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TModelSpec[]>,
|
||||
{} as Record<string, { specs: TModelSpec[]; groupIcon?: string }>,
|
||||
);
|
||||
|
||||
// Render each custom group
|
||||
return Object.entries(customGroups).map(([groupName, specs]) => (
|
||||
<CustomGroup key={groupName} groupName={groupName} specs={specs} />
|
||||
return Object.entries(customGroups).map(([groupName, { specs, groupIcon }]) => (
|
||||
<CustomGroup key={groupName} groupName={groupName} specs={specs} groupIcon={groupIcon} />
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SettingsIcon } from 'lucide-react';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { Spinner, TooltipAnchor } from '@librechat/client';
|
||||
import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import type { TModelSpec } from 'librechat-data-provider';
|
||||
import type { Endpoint } from '~/common';
|
||||
|
|
@ -14,6 +14,7 @@ import { cn } from '~/utils';
|
|||
|
||||
interface EndpointItemProps {
|
||||
endpoint: Endpoint;
|
||||
endpointIndex: number;
|
||||
}
|
||||
|
||||
const SettingsButton = ({
|
||||
|
|
@ -27,34 +28,58 @@ const SettingsButton = ({
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const text = localize('com_endpoint_config_key');
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!endpoint.value) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (endpoint.value) {
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e as unknown as React.MouseEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`endpoint-${endpoint.value}-settings`}
|
||||
onClick={(e) => {
|
||||
if (!endpoint.value) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
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',
|
||||
'group/button flex items-center gap-1.5 rounded-md px-1.5',
|
||||
'text-text-secondary transition-colors duration-150',
|
||||
'hover:bg-surface-tertiary hover:text-text-primary',
|
||||
'focus-visible:bg-surface-tertiary focus-visible:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
||||
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" aria-hidden="true" />
|
||||
<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>
|
||||
<SettingsIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-columns,opacity] duration-150 ease-out',
|
||||
'grid-cols-[0fr] opacity-0',
|
||||
'group-hover/button:grid-cols-[1fr] group-hover/button:opacity-100',
|
||||
'group-focus-visible/button:grid-cols-[1fr] group-focus-visible/button:opacity-100',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate pr-0.5">{text}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export function EndpointItem({ endpoint }: EndpointItemProps) {
|
||||
export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
agentsMap,
|
||||
|
|
@ -87,21 +112,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
[endpointRequiresUserKey, endpoint.value],
|
||||
);
|
||||
|
||||
const isAssistantsNotLoaded =
|
||||
isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined;
|
||||
|
||||
const renderIconLabel = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{endpoint.icon && (
|
||||
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-left',
|
||||
isUserProvided ? 'group-hover:w-24 group-focus:w-24' : '',
|
||||
)}
|
||||
>
|
||||
{endpoint.label}
|
||||
</span>
|
||||
<span className="truncate text-left">{endpoint.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -123,17 +144,14 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
<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=" " />}
|
||||
comboboxLabel={placeholder}
|
||||
onClick={() => handleSelectEndpoint(endpoint)}
|
||||
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"
|
||||
>
|
||||
<div className="group flex w-full min-w-0 items-center justify-between gap-1.5 py-1 text-sm">
|
||||
{renderIconLabel()}
|
||||
{isUserProvided && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
|
|
@ -142,8 +160,12 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
}
|
||||
>
|
||||
{isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined ? (
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Spinner />
|
||||
<div
|
||||
className="flex items-center justify-center p-2"
|
||||
role="status"
|
||||
aria-label={localize('com_ui_loading')}
|
||||
>
|
||||
<Spinner aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -153,8 +175,21 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
))}
|
||||
{/* Render endpoint models */}
|
||||
{filteredModels
|
||||
? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
|
||||
: endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)}
|
||||
? renderEndpointModels(
|
||||
endpoint,
|
||||
endpoint.models || [],
|
||||
selectedModel,
|
||||
filteredModels,
|
||||
endpointIndex,
|
||||
)
|
||||
: endpoint.models &&
|
||||
renderEndpointModels(
|
||||
endpoint,
|
||||
endpoint.models,
|
||||
selectedModel,
|
||||
undefined,
|
||||
endpointIndex,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
|
|
@ -165,32 +200,27 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
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"
|
||||
className="group flex w-full cursor-pointer items-center justify-between gap-1.5 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>
|
||||
{renderIconLabel()}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{endpointRequiresUserKey(endpoint.value) && (
|
||||
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
|
||||
)}
|
||||
{isAssistantsNotLoaded && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_click_to_view_var', { 0: endpoint.label })}
|
||||
side="top"
|
||||
render={
|
||||
<span className="flex items-center">
|
||||
<MousePointerClick className="size-4 text-text-secondary" aria-hidden="true" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedEndpoint === endpoint.value && !isAssistantsNotLoaded && (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
|
|
@ -198,7 +228,11 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
}
|
||||
|
||||
export function renderEndpoints(mappedEndpoints: Endpoint[]) {
|
||||
return mappedEndpoints.map((endpoint) => (
|
||||
<EndpointItem endpoint={endpoint} key={`endpoint-${endpoint.value}-item`} />
|
||||
return mappedEndpoints.map((endpoint, index) => (
|
||||
<EndpointItem
|
||||
endpoint={endpoint}
|
||||
endpointIndex={index}
|
||||
key={`endpoint-${endpoint.value}-${index}`}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
|
|||
return (
|
||||
<MenuItem
|
||||
ref={itemRef}
|
||||
key={modelId}
|
||||
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
||||
className="group flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
|
||||
>
|
||||
|
|
@ -161,14 +160,16 @@ export function renderEndpointModels(
|
|||
models: Array<{ name: string; isGlobal?: boolean }>,
|
||||
selectedModel: string | null,
|
||||
filteredModels?: string[],
|
||||
endpointIndex?: number,
|
||||
) {
|
||||
const modelsToRender = filteredModels || models.map((model) => model.name);
|
||||
const indexSuffix = endpointIndex != null ? `-${endpointIndex}` : '';
|
||||
|
||||
return modelsToRender.map(
|
||||
(modelId) =>
|
||||
(modelId, modelIndex) =>
|
||||
endpoint && (
|
||||
<EndpointModelItem
|
||||
key={modelId}
|
||||
key={`${endpoint.value}${indexSuffix}-${modelId}-${modelIndex}`}
|
||||
modelId={modelId}
|
||||
endpoint={endpoint}
|
||||
isSelected={selectedModel === modelId}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import React, { memo, useState } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
|
||||
interface GroupIconProps {
|
||||
iconURL: string;
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
type IconType = (props: IconMapProps) => React.JSX.Element;
|
||||
|
||||
const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
// Check if the iconURL is a built-in icon key
|
||||
if (iconURL in icons) {
|
||||
const Icon: IconType = (icons[iconURL] ?? icons.unknown) as IconType;
|
||||
return <Icon size={20} context="menu-item" className="icon-md shrink-0 text-text-primary" />;
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
const DefaultIcon: IconType = icons.unknown as IconType;
|
||||
return (
|
||||
<div className="relative" style={{ width: 20, height: 20, margin: '2px' }}>
|
||||
<div className="icon-md shrink-0 overflow-hidden rounded-full">
|
||||
<DefaultIcon context="menu-item" size={20} />
|
||||
</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="icon-md shrink-0 overflow-hidden rounded-full"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<img
|
||||
src={iconURL}
|
||||
alt={groupName}
|
||||
className="h-full w-full object-cover"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GroupIcon);
|
||||
|
|
@ -29,7 +29,7 @@ export default function HeaderNewChat() {
|
|||
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"
|
||||
className="rounded-xl duration-0 hover:bg-surface-active-alt max-md:hidden"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<NewChatIcon />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const data: TModelSpec[] = [
|
|||
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
|
||||
preset: {
|
||||
chatGptLabel: 'Vision Helper',
|
||||
greeting: 'What\'s up!!',
|
||||
greeting: "What's up!!",
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
model: 'gpt-4-turbo',
|
||||
promptPrefix:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { startTransition } from 'react';
|
||||
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -17,9 +18,13 @@ export default function OpenSidebar({
|
|||
const localize = useLocalize();
|
||||
|
||||
const handleClick = () => {
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
// Use startTransition to mark this as a non-urgent update
|
||||
// This prevents blocking the main thread during the cascade of re-renders
|
||||
startTransition(() => {
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
});
|
||||
// Delay focus until after the sidebar animation completes (200ms)
|
||||
setTimeout(() => {
|
||||
|
|
@ -39,10 +44,7 @@ export default function OpenSidebar({
|
|||
aria-label={localize('com_nav_open_sidebar')}
|
||||
aria-expanded={false}
|
||||
aria-controls="chat-history-nav"
|
||||
className={cn(
|
||||
'rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover',
|
||||
className,
|
||||
)}
|
||||
className={cn('rounded-xl duration-0 hover:bg-surface-active-alt', className)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Sidebar aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -49,16 +49,22 @@ const PresetsMenu: FC = () => {
|
|||
<Trigger asChild>
|
||||
<TooltipAnchor
|
||||
ref={presetsMenuTriggerRef}
|
||||
id="presets-button"
|
||||
aria-label={localize('com_endpoint_examples')}
|
||||
description={localize('com_endpoint_examples')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
data-testid="presets-button"
|
||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl 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-hidden="true" />
|
||||
</TooltipAnchor>
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
tabIndex={0}
|
||||
id="presets-button"
|
||||
data-testid="presets-button"
|
||||
aria-label={localize('com_endpoint_examples')}
|
||||
className="rounded-xl p-2 duration-0 hover:bg-surface-active-alt max-md:hidden"
|
||||
// className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl 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 className="icon-lg" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
</Trigger>
|
||||
<Portal>
|
||||
<div
|
||||
|
|
@ -74,7 +80,7 @@ const PresetsMenu: FC = () => {
|
|||
<Content
|
||||
side="bottom"
|
||||
align="center"
|
||||
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white md:min-w-[400px]"
|
||||
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary shadow-lg md:min-w-[400px]"
|
||||
>
|
||||
<PresetItems
|
||||
presets={presetsQuery.data}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className={cn('flex items-center gap-1 ')}>
|
||||
<div className={cn('flex items-center gap-1')}>
|
||||
{icon != null ? icon : null}
|
||||
<div className={cn('truncate', textClassName)}>
|
||||
{title}
|
||||
|
|
@ -72,7 +72,7 @@ const MenuItem: FC<MenuItemProps> = ({
|
|||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block "
|
||||
className="icon-md block"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { memo, useMemo, useCallback } from 'react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TMessageContentParts,
|
||||
|
|
@ -7,10 +7,12 @@ import type {
|
|||
Agents,
|
||||
} from 'librechat-data-provider';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
|
||||
import { mapAttachments } from '~/utils';
|
||||
import { EditTextPart, EmptyText } from './Parts';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { EditTextPart } from './Parts';
|
||||
import Container from './Container';
|
||||
import Part from './Part';
|
||||
|
||||
type ContentPartsProps = {
|
||||
|
|
@ -32,112 +34,159 @@ type ContentPartsProps = {
|
|||
| undefined;
|
||||
};
|
||||
|
||||
const ContentParts = memo(
|
||||
({
|
||||
content,
|
||||
messageId,
|
||||
conversationId,
|
||||
attachments,
|
||||
searchResults,
|
||||
isCreatedByUser,
|
||||
isLast,
|
||||
isSubmitting,
|
||||
isLatestMessage,
|
||||
edit,
|
||||
enterEdit,
|
||||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: ContentPartsProps) => {
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
/**
|
||||
* ContentParts renders message content parts, handling both sequential and parallel layouts.
|
||||
*
|
||||
* For 90% of messages (single-agent, no parallel execution), this renders sequentially.
|
||||
* For multi-agent parallel execution, it uses ParallelContentRenderer to show columns.
|
||||
*/
|
||||
const ContentParts = memo(function ContentParts({
|
||||
edit,
|
||||
isLast,
|
||||
content,
|
||||
messageId,
|
||||
enterEdit,
|
||||
siblingIdx,
|
||||
attachments,
|
||||
isSubmitting,
|
||||
setSiblingIdx,
|
||||
searchResults,
|
||||
conversationId,
|
||||
isCreatedByUser,
|
||||
isLatestMessage,
|
||||
}: ContentPartsProps) {
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
/**
|
||||
* Render a single content part with proper context.
|
||||
*/
|
||||
const renderPart = useCallback(
|
||||
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
|
||||
const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
if (edit === true && enterEdit && setSiblingIdx) {
|
||||
return (
|
||||
<>
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
const isTextPart =
|
||||
part?.type === ContentTypes.TEXT ||
|
||||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
|
||||
const isThinkPart =
|
||||
part?.type === ContentTypes.THINK ||
|
||||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
|
||||
if (!isTextPart && !isThinkPart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isToolCall =
|
||||
part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
|
||||
if (isToolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EditTextPart
|
||||
index={idx}
|
||||
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
|
||||
messageId={messageId}
|
||||
isSubmitting={isSubmitting}
|
||||
enterEdit={enterEdit}
|
||||
siblingIdx={siblingIdx ?? null}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
key={`edit-${messageId}-${idx}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: true,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content?.[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={partAttachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={isLastPart}
|
||||
showCursor={isLastPart && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
attachmentMap,
|
||||
content,
|
||||
conversationId,
|
||||
effectiveIsSubmitting,
|
||||
isCreatedByUser,
|
||||
isLast,
|
||||
isLatestMessage,
|
||||
messageId,
|
||||
],
|
||||
);
|
||||
|
||||
// Early return: no content
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Edit mode: render editable text parts
|
||||
if (edit === true && enterEdit && setSiblingIdx) {
|
||||
return (
|
||||
<>
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
{content.map((part, idx) => {
|
||||
if (!part) {
|
||||
return null;
|
||||
}
|
||||
const isTextPart =
|
||||
part?.type === ContentTypes.TEXT ||
|
||||
typeof (part as unknown as Agents.MessageContentText)?.text !== 'string';
|
||||
const isThinkPart =
|
||||
part?.type === ContentTypes.THINK ||
|
||||
typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think !== 'string';
|
||||
if (!isTextPart && !isThinkPart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||
const partAttachments = attachmentMap[toolCallId];
|
||||
const isToolCall = part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
|
||||
if (isToolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContext.Provider
|
||||
key={`provider-${messageId}-${idx}`}
|
||||
value={{
|
||||
messageId,
|
||||
isExpanded: true,
|
||||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={partAttachments}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
showCursor={idx === content.length - 1 && isLast}
|
||||
/>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
})}
|
||||
</SearchContext.Provider>
|
||||
return (
|
||||
<EditTextPart
|
||||
index={idx}
|
||||
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
|
||||
messageId={messageId}
|
||||
isSubmitting={isSubmitting}
|
||||
enterEdit={enterEdit}
|
||||
siblingIdx={siblingIdx ?? null}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
key={`edit-${messageId}-${idx}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const showEmptyCursor = content.length === 0 && effectiveIsSubmitting;
|
||||
const lastContentIdx = content.length - 1;
|
||||
|
||||
// Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata)
|
||||
const hasParallelContent = content.some((part) => part?.groupId != null);
|
||||
if (hasParallelContent) {
|
||||
return (
|
||||
<ParallelContentRenderer
|
||||
content={content}
|
||||
messageId={messageId}
|
||||
conversationId={conversationId}
|
||||
attachments={attachments}
|
||||
searchResults={searchResults}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
renderPart={renderPart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Sequential content: render parts in order (90% of cases)
|
||||
const sequentialParts: PartWithIndex[] = [];
|
||||
content.forEach((part, idx) => {
|
||||
if (part) {
|
||||
sequentialParts.push({ part, idx });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
{showEmptyCursor && (
|
||||
<Container>
|
||||
<EmptyText />
|
||||
</Container>
|
||||
)}
|
||||
{sequentialParts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export default ContentParts;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { TextareaAutosize, TooltipAnchor } from '@librechat/client';
|
||||
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TEditProps } from '~/common';
|
||||
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
|
||||
import { useMessagesOperations, useMessagesConversation } from '~/Providers';
|
||||
import { useGetAddedConvo } from '~/hooks/Chat';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
|
|
@ -19,14 +20,10 @@ const EditMessage = ({
|
|||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: TEditProps) => {
|
||||
const { addedIndex } = useAddedChatContext();
|
||||
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const { conversation } = useMessagesConversation();
|
||||
const { getMessages, setMessages } = useMessagesOperations();
|
||||
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
||||
store.latestMessageFamily(addedIndex),
|
||||
);
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
|
|
@ -37,6 +34,8 @@ const EditMessage = ({
|
|||
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
|
||||
const isRTL = chatDirection === 'rtl';
|
||||
|
||||
const getAddedConvo = useGetAddedConvo();
|
||||
|
||||
const { register, handleSubmit, setValue } = useForm({
|
||||
defaultValues: {
|
||||
text: text ?? '',
|
||||
|
|
@ -62,6 +61,7 @@ const EditMessage = ({
|
|||
},
|
||||
{
|
||||
overrideFiles: message.files,
|
||||
addedConvo: getAddedConvo() || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -80,6 +80,7 @@ const EditMessage = ({
|
|||
editedMessageId: messageId,
|
||||
isRegenerate: true,
|
||||
isEdited: true,
|
||||
addedConvo: getAddedConvo() || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -101,10 +102,6 @@ const EditMessage = ({
|
|||
messageId,
|
||||
});
|
||||
|
||||
if (message.messageId === latestMultiMessage?.messageId) {
|
||||
setLatestMultiMessage({ ...latestMultiMessage, text: data.text });
|
||||
}
|
||||
|
||||
const isInMessages = messages.some((message) => message.messageId === messageId);
|
||||
if (!isInMessages) {
|
||||
message.text = data.text;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import React, { memo, useMemo, useRef, useEffect } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
|
||||
import MermaidErrorBoundary from '~/components/Messages/Content/MermaidErrorBoundary';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import Mermaid from '~/components/Messages/Content/Mermaid';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import { useCodeBlockContext } from '~/Providers';
|
||||
|
|
@ -24,10 +26,11 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMath = lang === 'math';
|
||||
const isMermaid = lang === 'mermaid';
|
||||
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
|
||||
|
||||
const { getNextIndex, resetCounter } = useCodeBlockContext();
|
||||
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
|
||||
const blockIndex = useRef(getNextIndex(isMath || isMermaid || isSingleLine)).current;
|
||||
|
||||
useEffect(() => {
|
||||
resetCounter();
|
||||
|
|
@ -35,6 +38,13 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
|
||||
if (isMath) {
|
||||
return <>{children}</>;
|
||||
} else if (isMermaid) {
|
||||
const content = typeof children === 'string' ? children : String(children);
|
||||
return (
|
||||
<MermaidErrorBoundary code={content}>
|
||||
<Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>
|
||||
</MermaidErrorBoundary>
|
||||
);
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
@ -59,6 +69,9 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }:
|
|||
|
||||
if (lang === 'math') {
|
||||
return children;
|
||||
} else if (lang === 'mermaid') {
|
||||
const content = typeof children === 'string' ? children : String(children);
|
||||
return <Mermaid>{content}</Mermaid>;
|
||||
} else if (typeof children === 'string' && children.split('\n').length === 1) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
|
|||
269
client/src/components/Chat/Messages/Content/ParallelContent.tsx
Normal file
269
client/src/components/Chat/Messages/Content/ParallelContent.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider';
|
||||
import { SearchContext } from '~/Providers';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import Sources from '~/components/Web/Sources';
|
||||
import { EmptyText } from './Parts';
|
||||
import SiblingHeader from './SiblingHeader';
|
||||
import Container from './Container';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export type PartWithIndex = { part: TMessageContentParts; idx: number };
|
||||
|
||||
export type ParallelColumn = {
|
||||
agentId: string;
|
||||
parts: PartWithIndex[];
|
||||
};
|
||||
|
||||
export type ParallelSection = {
|
||||
groupId: number;
|
||||
columns: ParallelColumn[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups content parts by groupId for parallel rendering.
|
||||
* Parts with same groupId are displayed in columns, grouped by agentId.
|
||||
*
|
||||
* @param content - Array of content parts
|
||||
* @returns Object containing parallel sections and sequential parts
|
||||
*/
|
||||
export function groupParallelContent(
|
||||
content: Array<TMessageContentParts | undefined> | undefined,
|
||||
): { parallelSections: ParallelSection[]; sequentialParts: PartWithIndex[] } {
|
||||
if (!content) {
|
||||
return { parallelSections: [], sequentialParts: [] };
|
||||
}
|
||||
|
||||
const groupMap = new Map<number, PartWithIndex[]>();
|
||||
// Track placeholder agentIds per groupId (parts with empty type that establish columns)
|
||||
const placeholderAgents = new Map<number, Set<string>>();
|
||||
const noGroup: PartWithIndex[] = [];
|
||||
|
||||
content.forEach((part, idx) => {
|
||||
if (!part) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read metadata directly from content part (TMessageContentParts includes ContentMetadata)
|
||||
const { groupId } = part;
|
||||
|
||||
// Check for placeholder (empty type) before narrowing - access agentId via casting
|
||||
const partAgentId = (part as { agentId?: string }).agentId;
|
||||
|
||||
if (groupId != null) {
|
||||
// Track placeholder parts (empty type) to establish columns for pending agents
|
||||
if (!part.type && partAgentId) {
|
||||
if (!placeholderAgents.has(groupId)) {
|
||||
placeholderAgents.set(groupId, new Set());
|
||||
}
|
||||
placeholderAgents.get(groupId)!.add(partAgentId);
|
||||
return; // Don't add to groupMap - we'll handle these separately
|
||||
}
|
||||
|
||||
if (!groupMap.has(groupId)) {
|
||||
groupMap.set(groupId, []);
|
||||
}
|
||||
groupMap.get(groupId)!.push({ part, idx });
|
||||
} else {
|
||||
noGroup.push({ part, idx });
|
||||
}
|
||||
});
|
||||
|
||||
// Collect all groupIds (from both real content and placeholders)
|
||||
const allGroupIds = new Set([...groupMap.keys(), ...placeholderAgents.keys()]);
|
||||
|
||||
// Build parallel sections with columns grouped by agentId
|
||||
const sections: ParallelSection[] = [];
|
||||
for (const groupId of allGroupIds) {
|
||||
const columnMap = new Map<string, PartWithIndex[]>();
|
||||
const parts = groupMap.get(groupId) ?? [];
|
||||
|
||||
for (const { part, idx } of parts) {
|
||||
// Read agentId directly from content part (TMessageContentParts includes ContentMetadata)
|
||||
const agentId = part.agentId ?? 'unknown';
|
||||
|
||||
if (!columnMap.has(agentId)) {
|
||||
columnMap.set(agentId, []);
|
||||
}
|
||||
columnMap.get(agentId)!.push({ part, idx });
|
||||
}
|
||||
|
||||
// Add empty columns for placeholder agents that don't have real content yet
|
||||
const groupPlaceholders = placeholderAgents.get(groupId);
|
||||
if (groupPlaceholders) {
|
||||
for (const placeholderAgentId of groupPlaceholders) {
|
||||
if (!columnMap.has(placeholderAgentId)) {
|
||||
// Empty array signals this column should show loading state
|
||||
columnMap.set(placeholderAgentId, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort columns: primary agent (no ____N suffix) first, added agents (with suffix) second
|
||||
// This ensures consistent column ordering regardless of which agent responds first
|
||||
const sortedAgentIds = Array.from(columnMap.keys()).sort((a, b) => {
|
||||
const aHasSuffix = a.includes('____');
|
||||
const bHasSuffix = b.includes('____');
|
||||
if (aHasSuffix && !bHasSuffix) {
|
||||
return 1;
|
||||
}
|
||||
if (!aHasSuffix && bHasSuffix) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const columns = sortedAgentIds.map((agentId) => ({
|
||||
agentId,
|
||||
parts: columnMap.get(agentId)!,
|
||||
}));
|
||||
|
||||
sections.push({ groupId, columns });
|
||||
}
|
||||
|
||||
// Sort sections by the minimum index in each section (sections with only placeholders go last)
|
||||
sections.sort((a, b) => {
|
||||
const aParts = a.columns.flatMap((c) => c.parts.map((p) => p.idx));
|
||||
const bParts = b.columns.flatMap((c) => c.parts.map((p) => p.idx));
|
||||
const aMin = aParts.length > 0 ? Math.min(...aParts) : Infinity;
|
||||
const bMin = bParts.length > 0 ? Math.min(...bParts) : Infinity;
|
||||
return aMin - bMin;
|
||||
});
|
||||
|
||||
return { parallelSections: sections, sequentialParts: noGroup };
|
||||
}
|
||||
|
||||
type ParallelColumnsProps = {
|
||||
columns: ParallelColumn[];
|
||||
groupId: number;
|
||||
messageId: string;
|
||||
isSubmitting: boolean;
|
||||
lastContentIdx: number;
|
||||
conversationId?: string | null;
|
||||
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders parallel content columns for a single groupId.
|
||||
*/
|
||||
export const ParallelColumns = memo(function ParallelColumns({
|
||||
columns,
|
||||
groupId,
|
||||
messageId,
|
||||
conversationId,
|
||||
isSubmitting,
|
||||
lastContentIdx,
|
||||
renderPart,
|
||||
}: ParallelColumnsProps) {
|
||||
return (
|
||||
<div className={cn('flex w-full flex-col gap-3 md:flex-row', 'sibling-content-group')}>
|
||||
{columns.map(({ agentId, parts: columnParts }, colIdx) => {
|
||||
// Show loading cursor if column has no content parts yet (empty array from placeholder)
|
||||
const showLoadingCursor = isSubmitting && columnParts.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`column-${messageId}-${groupId}-${agentId || colIdx}`}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border-light p-3"
|
||||
>
|
||||
<SiblingHeader
|
||||
agentId={agentId}
|
||||
messageId={messageId}
|
||||
isSubmitting={isSubmitting}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
{showLoadingCursor ? (
|
||||
<Container>
|
||||
<EmptyText />
|
||||
</Container>
|
||||
) : (
|
||||
columnParts.map(({ part, idx }) => {
|
||||
const isLastInColumn = idx === columnParts[columnParts.length - 1]?.idx;
|
||||
const isLastContent = idx === lastContentIdx;
|
||||
return renderPart(part, idx, isLastInColumn && isLastContent);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type ParallelContentRendererProps = {
|
||||
content: Array<TMessageContentParts | undefined>;
|
||||
messageId: string;
|
||||
conversationId?: string | null;
|
||||
attachments?: TAttachment[];
|
||||
searchResults?: { [key: string]: SearchResultData };
|
||||
isSubmitting: boolean;
|
||||
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders content with parallel sections (columns) and sequential parts.
|
||||
* Handles the layout of before/parallel/after content sections.
|
||||
*/
|
||||
export const ParallelContentRenderer = memo(function ParallelContentRenderer({
|
||||
content,
|
||||
messageId,
|
||||
conversationId,
|
||||
attachments,
|
||||
searchResults,
|
||||
isSubmitting,
|
||||
renderPart,
|
||||
}: ParallelContentRendererProps) {
|
||||
const { parallelSections, sequentialParts } = useMemo(
|
||||
() => groupParallelContent(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const lastContentIdx = content.length - 1;
|
||||
|
||||
// Split sequential parts into before/after parallel sections
|
||||
const { before, after } = useMemo(() => {
|
||||
if (parallelSections.length === 0) {
|
||||
return { before: sequentialParts, after: [] };
|
||||
}
|
||||
|
||||
const allParallelIndices = parallelSections.flatMap((s) =>
|
||||
s.columns.flatMap((c) => c.parts.map((p) => p.idx)),
|
||||
);
|
||||
const minParallelIdx = Math.min(...allParallelIndices);
|
||||
const maxParallelIdx = Math.max(...allParallelIndices);
|
||||
|
||||
return {
|
||||
before: sequentialParts.filter(({ idx }) => idx < minParallelIdx),
|
||||
after: sequentialParts.filter(({ idx }) => idx > maxParallelIdx),
|
||||
};
|
||||
}, [parallelSections, sequentialParts]);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={{ searchResults }}>
|
||||
<MemoryArtifacts attachments={attachments} />
|
||||
<Sources messageId={messageId} conversationId={conversationId || undefined} />
|
||||
|
||||
{/* Sequential content BEFORE parallel sections */}
|
||||
{before.map(({ part, idx }) => renderPart(part, idx, false))}
|
||||
|
||||
{/* Parallel sections - each group renders as columns */}
|
||||
{parallelSections.map(({ groupId, columns }) => (
|
||||
<ParallelColumns
|
||||
key={`parallel-group-${messageId}-${groupId}`}
|
||||
columns={columns}
|
||||
groupId={groupId}
|
||||
messageId={messageId}
|
||||
renderPart={renderPart}
|
||||
isSubmitting={isSubmitting}
|
||||
conversationId={conversationId}
|
||||
lastContentIdx={lastContentIdx}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Sequential content AFTER parallel sections */}
|
||||
{after.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export default ParallelContentRenderer;
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { TextareaAutosize } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { Lightbulb, MessageSquare } from 'lucide-react';
|
||||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
|
||||
import { useMessagesOperations, useMessagesConversation } from '~/Providers';
|
||||
import Container from '~/components/Chat/Messages/Content/Container';
|
||||
import { useGetAddedConvo } from '~/hooks/Chat';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -25,12 +26,8 @@ const EditTextPart = ({
|
|||
part: Agents.MessageContentText | Agents.ReasoningDeltaUpdate;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { addedIndex } = useAddedChatContext();
|
||||
const { conversation } = useMessagesConversation();
|
||||
const { ask, getMessages, setMessages } = useMessagesOperations();
|
||||
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
||||
store.latestMessageFamily(addedIndex),
|
||||
);
|
||||
|
||||
const { conversationId = '' } = conversation ?? {};
|
||||
const message = useMemo(
|
||||
|
|
@ -40,6 +37,8 @@ const EditTextPart = ({
|
|||
|
||||
const chatDirection = useRecoilValue(store.chatDirection);
|
||||
|
||||
const getAddedConvo = useGetAddedConvo();
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? '');
|
||||
|
||||
|
|
@ -87,6 +86,7 @@ const EditTextPart = ({
|
|||
editedMessageId: messageId,
|
||||
isRegenerate: true,
|
||||
isEdited: true,
|
||||
addedConvo: getAddedConvo() || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -105,10 +105,6 @@ const EditTextPart = ({
|
|||
messageId,
|
||||
});
|
||||
|
||||
if (messageId === latestMultiMessage?.messageId) {
|
||||
setLatestMultiMessage({ ...latestMultiMessage, text: data.text });
|
||||
}
|
||||
|
||||
const isInMessages = messages.some((msg) => msg.messageId === messageId);
|
||||
if (!isInMessages) {
|
||||
return enterEdit(true);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function ProgressCircle({
|
|||
className="absolute left-1/2 top-1/2 h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 text-brand-purple"
|
||||
>
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
|
||||
className="stroke-brand-purple/25 dark:stroke-brand-purple/50 origin-[50%_50%] -rotate-90"
|
||||
strokeWidth="7.826086956521739"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
|
|
|
|||
140
client/src/components/Chat/Messages/Content/SiblingHeader.tsx
Normal file
140
client/src/components/Chat/Messages/Content/SiblingHeader.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useMemo } from 'react';
|
||||
import { GitBranchPlus } from 'lucide-react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { EModelEndpoint, parseEphemeralAgentId, stripAgentIdSuffix } from 'librechat-data-provider';
|
||||
import type { TMessage, Agent } from 'librechat-data-provider';
|
||||
import { useBranchMessageMutation } from '~/data-provider/Messages';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type SiblingHeaderProps = {
|
||||
/** The agentId from the content part (could be real agent ID or endpoint__model format) */
|
||||
agentId?: string;
|
||||
/** The messageId of the parent message */
|
||||
messageId?: string;
|
||||
/** The conversationId */
|
||||
conversationId?: string | null;
|
||||
/** Whether a submission is in progress */
|
||||
isSubmitting?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Header component for sibling content parts in parallel agent responses.
|
||||
* Displays the agent/model icon and name for each parallel response.
|
||||
*/
|
||||
export default function SiblingHeader({
|
||||
agentId,
|
||||
messageId,
|
||||
conversationId,
|
||||
isSubmitting,
|
||||
}: SiblingHeaderProps) {
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const branchMessage = useBranchMessageMutation(conversationId ?? null, {
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_branch_created'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_branch_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleBranch = () => {
|
||||
if (!messageId || !agentId || isSubmitting || branchMessage.isLoading) {
|
||||
return;
|
||||
}
|
||||
branchMessage.mutate({ messageId, agentId });
|
||||
};
|
||||
|
||||
const { displayName, displayEndpoint, displayModel, agent } = useMemo(() => {
|
||||
// First, try to look up as a real agent
|
||||
if (agentId) {
|
||||
// Strip ____N suffix if present (used to distinguish parallel agents with same ID)
|
||||
const baseAgentId = stripAgentIdSuffix(agentId);
|
||||
|
||||
const foundAgent = agentsMap?.[baseAgentId] as Agent | undefined;
|
||||
if (foundAgent) {
|
||||
return {
|
||||
displayName: foundAgent.name,
|
||||
displayEndpoint: EModelEndpoint.agents,
|
||||
displayModel: foundAgent.model,
|
||||
agent: foundAgent,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse as ephemeral agent ID (endpoint__model___sender format)
|
||||
const parsed = parseEphemeralAgentId(agentId);
|
||||
if (parsed) {
|
||||
return {
|
||||
displayName: parsed.sender || parsed.model || 'AI',
|
||||
displayEndpoint: parsed.endpoint,
|
||||
displayModel: parsed.model,
|
||||
agent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// agentId exists but couldn't be parsed as ephemeral - use it as-is for display
|
||||
return {
|
||||
displayName: baseAgentId,
|
||||
displayEndpoint: EModelEndpoint.agents,
|
||||
displayModel: undefined,
|
||||
agent: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Use message model/endpoint as last resort
|
||||
return {
|
||||
displayName: 'Agent',
|
||||
displayEndpoint: EModelEndpoint.agents,
|
||||
displayModel: undefined,
|
||||
agent: undefined,
|
||||
};
|
||||
}, [agentId, agentsMap]);
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border-light pb-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
endpoint: displayEndpoint,
|
||||
model: displayModel,
|
||||
isCreatedByUser: false,
|
||||
} as TMessage
|
||||
}
|
||||
agent={agent || undefined}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
|
||||
</div>
|
||||
{messageId && agentId && !isSubmitting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBranch}
|
||||
disabled={isSubmitting || branchMessage.isLoading}
|
||||
className={cn(
|
||||
'flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md',
|
||||
'text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus:outline-none focus:ring-2 focus:ring-border-medium focus:ring-offset-1',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
aria-label={localize('com_ui_branch_message')}
|
||||
title={localize('com_ui_branch_message')}
|
||||
>
|
||||
<GitBranchPlus className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ function FeedbackButtons({
|
|||
|
||||
function buttonClasses(isActive: boolean, isLast: boolean) {
|
||||
return cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export default function Fork({
|
|||
});
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const HoverButton = memo(
|
|||
className = '',
|
||||
}: HoverButtonProps) => {
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
|
|
@ -213,7 +213,10 @@ const HoverButtons = ({
|
|||
}
|
||||
icon={isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
isLast={isLast}
|
||||
className={`ml-0 flex items-center gap-1.5 text-xs ${isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : ''}`}
|
||||
className={cn(
|
||||
'ml-0 flex items-center gap-1.5 text-xs',
|
||||
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import React from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import MessageRender from './ui/MessageRender';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const MessageContainer = React.memo(
|
||||
({
|
||||
|
|
@ -29,16 +25,10 @@ const MessageContainer = React.memo(
|
|||
);
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const {
|
||||
showSibling,
|
||||
conversation,
|
||||
handleScroll,
|
||||
siblingMessage,
|
||||
latestMultiMessage,
|
||||
isSubmittingFamily,
|
||||
} = useMessageProcess({ message: props.message });
|
||||
const { conversation, handleScroll } = useMessageProcess({
|
||||
message: props.message,
|
||||
});
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null;
|
||||
|
|
@ -49,34 +39,9 @@ export default function Message(props: TMessageProps) {
|
|||
return (
|
||||
<>
|
||||
<MessageContainer handleScroll={handleScroll}>
|
||||
{showSibling ? (
|
||||
<div className="m-auto my-2 flex justify-center p-4 py-2 md:gap-6">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-row flex-wrap justify-between gap-1 md:flex-nowrap md:gap-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-5xl xl:max-w-6xl',
|
||||
)}
|
||||
>
|
||||
<MessageRender
|
||||
{...props}
|
||||
message={message}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
isCard
|
||||
/>
|
||||
<MessageRender
|
||||
{...props}
|
||||
isMultiMessage
|
||||
isCard
|
||||
message={siblingMessage ?? latestMultiMessage ?? undefined}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<MessageRender {...props} />
|
||||
</div>
|
||||
)}
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<MessageRender {...props} />
|
||||
</div>
|
||||
</MessageContainer>
|
||||
<MultiMessage
|
||||
key={messageId}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks';
|
||||
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import ContentParts from './Content/ContentParts';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
|
|
@ -75,15 +75,25 @@ export default function Message(props: TMessageProps) {
|
|||
],
|
||||
);
|
||||
|
||||
const { hasParallelContent } = useContentMetadata(message);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getChatWidthClass = () => {
|
||||
if (maximizeChatSpace) {
|
||||
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
|
||||
}
|
||||
if (hasParallelContent) {
|
||||
return 'md:max-w-[58rem] xl:max-w-[70rem]';
|
||||
}
|
||||
return 'md:max-w-[47rem] xl:max-w-[55rem]';
|
||||
};
|
||||
|
||||
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]',
|
||||
chat: getChatWidthClass(),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -99,20 +109,25 @@ export default function Message(props: TMessageProps) {
|
|||
aria-label={getMessageAriaLabel(message, localize)}
|
||||
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} />
|
||||
{!hasParallelContent && (
|
||||
<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>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-11/12 flex-col',
|
||||
'relative flex flex-col',
|
||||
hasParallelContent ? 'w-full' : 'w-11/12',
|
||||
isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
)}
|
||||
>
|
||||
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
|
||||
{name}
|
||||
</h2>
|
||||
{!hasParallelContent && (
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
|
|
@ -21,6 +21,7 @@ function MessagesViewContent({
|
|||
const { screenshotTargetRef } = useScreenshot();
|
||||
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||
const scrollToBottomRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
conversation,
|
||||
|
|
@ -87,8 +88,9 @@ function MessagesViewContent({
|
|||
classNames="scroll-animation"
|
||||
unmountOnExit={true}
|
||||
appear={true}
|
||||
nodeRef={scrollToBottomRef}
|
||||
>
|
||||
<ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
<ScrollToBottom ref={scrollToBottomRef} scrollHandler={handleSmoothToRef} />
|
||||
</CSSTransition>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function SiblingSwitch({
|
|||
};
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
|
||||
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
|
||||
'hover:text-text-primary hover:bg-surface-hover',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
|
|
|
|||
|
|
@ -8,18 +8,16 @@ import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
|||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, getMessageAriaLabel } from '~/utils';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { MessageContext } from '~/Providers';
|
||||
import { useLocalize, useMessageActions } from '~/hooks';
|
||||
import { cn, getMessageAriaLabel, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type MessageRenderProps = {
|
||||
message?: TMessage;
|
||||
isCard?: boolean;
|
||||
isMultiMessage?: boolean;
|
||||
isSubmittingFamily?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
} & Pick<
|
||||
TMessageProps,
|
||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
|
|
@ -28,14 +26,12 @@ type MessageRenderProps = {
|
|||
const MessageRender = memo(
|
||||
({
|
||||
message: msg,
|
||||
isCard = false,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
currentEditId,
|
||||
isMultiMessage = false,
|
||||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
isSubmitting = false,
|
||||
}: MessageRenderProps) => {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
|
|
@ -47,17 +43,14 @@ const MessageRender = memo(
|
|||
enterEdit,
|
||||
conversation,
|
||||
messageLabel,
|
||||
isSubmitting,
|
||||
latestMessage,
|
||||
handleFeedback,
|
||||
handleContinue,
|
||||
copyToClipboard,
|
||||
setLatestMessage,
|
||||
regenerateMessage,
|
||||
handleFeedback,
|
||||
} = useMessageActions({
|
||||
message: msg,
|
||||
currentEditId,
|
||||
isMultiMessage,
|
||||
setCurrentEditId,
|
||||
});
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
|
@ -70,9 +63,6 @@ const MessageRender = memo(
|
|||
[hasNoChildren, msg?.depth, latestMessage?.depth],
|
||||
);
|
||||
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
|
||||
const showCardRender = isLast && !isSubmittingFamily && isCard;
|
||||
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
|
||||
|
||||
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
|
|
@ -95,36 +85,28 @@ const MessageRender = memo(
|
|||
],
|
||||
);
|
||||
|
||||
const clickHandler = useMemo(
|
||||
() =>
|
||||
showCardRender && !isLatestMessage
|
||||
? () => {
|
||||
logger.log(
|
||||
'latest_message',
|
||||
`Message Card click: Setting ${msg?.messageId} as latest message`,
|
||||
);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg!);
|
||||
}
|
||||
: undefined,
|
||||
[showCardRender, isLatestMessage, msg, setLatestMessage],
|
||||
);
|
||||
const { hasParallelContent } = useContentMetadata(msg);
|
||||
|
||||
if (!msg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getChatWidthClass = () => {
|
||||
if (maximizeChatSpace) {
|
||||
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
|
||||
}
|
||||
if (hasParallelContent) {
|
||||
return 'md:max-w-[58rem] xl:max-w-[70rem]';
|
||||
}
|
||||
return 'md:max-w-[47rem] xl:max-w-[55rem]';
|
||||
};
|
||||
|
||||
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]',
|
||||
chat: getChatWidthClass(),
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
|
|
@ -134,38 +116,29 @@ const MessageRender = memo(
|
|||
aria-label={getMessageAriaLabel(msg, localize)}
|
||||
className={cn(
|
||||
baseClasses.common,
|
||||
isCard ? baseClasses.card : baseClasses.chat,
|
||||
conditionalClasses.latestCard,
|
||||
conditionalClasses.cardRender,
|
||||
baseClasses.chat,
|
||||
conditionalClasses.focus,
|
||||
'message-render',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
|
||||
clickHandler();
|
||||
}
|
||||
}}
|
||||
role={showCardRender ? 'button' : undefined}
|
||||
tabIndex={showCardRender ? 0 : undefined}
|
||||
>
|
||||
{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-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||
{!hasParallelContent && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-11/12 flex-col',
|
||||
'relative flex flex-col',
|
||||
hasParallelContent ? 'w-full' : 'w-11/12',
|
||||
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
)}
|
||||
>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
{!hasParallelContent && (
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
|
@ -194,9 +167,8 @@ const MessageRender = memo(
|
|||
/>
|
||||
</MessageContext.Provider>
|
||||
</div>
|
||||
|
||||
{hasNoChildren && (isSubmittingFamily === true || effectiveIsSubmitting) ? (
|
||||
<PlaceholderRow isCard={isCard} />
|
||||
{hasNoChildren && effectiveIsSubmitting ? (
|
||||
<PlaceholderRow />
|
||||
) : (
|
||||
<SubRow classes="text-xs">
|
||||
<SiblingSwitch
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
const PlaceholderRow = memo(({ isCard }: { isCard?: boolean }) => {
|
||||
if (!isCard) {
|
||||
return null;
|
||||
}
|
||||
const PlaceholderRow = memo(() => {
|
||||
return <div className="mt-1 h-[27px] bg-transparent" />;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ export function TemporaryChat() {
|
|||
|
||||
const temporaryBadge = {
|
||||
id: 'temporary',
|
||||
icon: MessageCircleDashed,
|
||||
label: 'com_ui_temporary' as const,
|
||||
atom: store.isTemporary,
|
||||
isAvailable: true,
|
||||
};
|
||||
|
|
@ -37,26 +35,20 @@ export function TemporaryChat() {
|
|||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize(temporaryBadge.label)}
|
||||
description={localize('com_ui_temporary')}
|
||||
render={
|
||||
<button
|
||||
onClick={handleBadgeToggle}
|
||||
aria-label={localize(temporaryBadge.label)}
|
||||
aria-label={localize('com_ui_temporary')}
|
||||
aria-pressed={isTemporary}
|
||||
className={cn(
|
||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
|
||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out',
|
||||
isTemporary
|
||||
? 'bg-surface-active shadow-md'
|
||||
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
|
||||
'active:shadow-inner',
|
||||
? 'bg-surface-active'
|
||||
: 'bg-presentation shadow-sm hover:bg-surface-active-alt',
|
||||
)}
|
||||
>
|
||||
{temporaryBadge.icon && (
|
||||
<temporaryBadge.icon
|
||||
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<MessageCircleDashed className="icon-lg" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtuali
|
|||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks';
|
||||
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
|
||||
import { useActiveJobs } from '~/data-provider';
|
||||
import { groupConversationsByDate, cn } from '~/utils';
|
||||
import Convo from './Convo';
|
||||
import store from '~/store';
|
||||
|
|
@ -120,18 +121,28 @@ const MemoizedConvo = memo(
|
|||
conversation,
|
||||
retainView,
|
||||
toggleNav,
|
||||
isGenerating,
|
||||
}: {
|
||||
conversation: TConversation;
|
||||
retainView: () => void;
|
||||
toggleNav: () => void;
|
||||
isGenerating: boolean;
|
||||
}) => {
|
||||
return <Convo conversation={conversation} retainView={retainView} toggleNav={toggleNav} />;
|
||||
return (
|
||||
<Convo
|
||||
conversation={conversation}
|
||||
retainView={retainView}
|
||||
toggleNav={toggleNav}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
|
||||
prevProps.conversation.title === nextProps.conversation.title &&
|
||||
prevProps.conversation.endpoint === nextProps.conversation.endpoint
|
||||
prevProps.conversation.endpoint === nextProps.conversation.endpoint &&
|
||||
prevProps.isGenerating === nextProps.isGenerating
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -149,11 +160,19 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const search = useRecoilValue(store.search);
|
||||
const resumableEnabled = useRecoilValue(store.resumableStreams);
|
||||
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const convoHeight = isSmallScreen ? 44 : 34;
|
||||
const showAgentMarketplace = useShowMarketplace();
|
||||
|
||||
// Fetch active job IDs for showing generation indicators
|
||||
const { data: activeJobsData } = useActiveJobs(resumableEnabled);
|
||||
const activeJobIds = useMemo(
|
||||
() => new Set(activeJobsData?.activeJobIds ?? []),
|
||||
[activeJobsData?.activeJobIds],
|
||||
);
|
||||
|
||||
// Determine if FavoritesList will render content
|
||||
const shouldShowFavorites =
|
||||
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
|
||||
|
|
@ -250,7 +269,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
|
||||
if (item.type === 'loading') {
|
||||
return (
|
||||
<MeasuredRow {...rowProps}>
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<LoadingSpinner />
|
||||
</MeasuredRow>
|
||||
);
|
||||
|
|
@ -258,7 +277,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
|
||||
if (item.type === 'favorites') {
|
||||
return (
|
||||
<MeasuredRow {...rowProps}>
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<FavoritesList
|
||||
isSmallScreen={isSmallScreen}
|
||||
toggleNav={toggleNav}
|
||||
|
|
@ -270,7 +289,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
|
||||
if (item.type === 'chats-header') {
|
||||
return (
|
||||
<MeasuredRow {...rowProps}>
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<ChatsHeader
|
||||
isExpanded={isChatsExpanded}
|
||||
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
|
||||
|
|
@ -285,16 +304,22 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
// Without favorites: [chats-header, first-header] → index 1
|
||||
const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
|
||||
return (
|
||||
<MeasuredRow {...rowProps}>
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
|
||||
</MeasuredRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'convo') {
|
||||
const isGenerating = activeJobIds.has(item.convo.conversationId ?? '');
|
||||
return (
|
||||
<MeasuredRow {...rowProps}>
|
||||
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
|
||||
<MeasuredRow key={key} {...rowProps}>
|
||||
<MemoizedConvo
|
||||
conversation={item.convo}
|
||||
retainView={moveToTop}
|
||||
toggleNav={toggleNav}
|
||||
isGenerating={isGenerating}
|
||||
/>
|
||||
</MeasuredRow>
|
||||
);
|
||||
}
|
||||
|
|
@ -311,6 +336,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
isChatsExpanded,
|
||||
setIsChatsExpanded,
|
||||
shouldShowFavorites,
|
||||
activeJobIds,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
|
|
@ -19,9 +19,15 @@ interface ConversationProps {
|
|||
conversation: TConversation;
|
||||
retainView: () => void;
|
||||
toggleNav: () => void;
|
||||
isGenerating?: boolean;
|
||||
}
|
||||
|
||||
export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) {
|
||||
export default function Conversation({
|
||||
conversation,
|
||||
retainView,
|
||||
toggleNav,
|
||||
isGenerating = false,
|
||||
}: ConversationProps) {
|
||||
const params = useParams();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
|
@ -37,6 +43,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
const [titleInput, setTitleInput] = useState(title || '');
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
// Lazy-load ConvoOptions to avoid running heavy hooks for all conversations
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
|
||||
const previousTitle = useRef(title);
|
||||
|
||||
|
|
@ -95,6 +103,12 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
setRenaming(false);
|
||||
};
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (!hasInteracted) {
|
||||
setHasInteracted(true);
|
||||
}
|
||||
}, [hasInteracted]);
|
||||
|
||||
const handleNavigation = (ctrlOrMetaKey: boolean) => {
|
||||
if (ctrlOrMetaKey) {
|
||||
toggleNav();
|
||||
|
|
@ -143,6 +157,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
aria-label={localize('com_ui_conversation_label', {
|
||||
title: title || localize('com_ui_untitled'),
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onFocus={handleMouseEnter}
|
||||
onClick={(e) => {
|
||||
if (renaming) {
|
||||
return;
|
||||
|
|
@ -183,12 +199,35 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
isSmallScreen={isSmallScreen}
|
||||
localize={localize}
|
||||
>
|
||||
<EndpointIcon
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
size={20}
|
||||
context="menu-item"
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<svg
|
||||
className="h-5 w-5 flex-shrink-0 animate-spin text-text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-label={localize('com_ui_generating')}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<EndpointIcon
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
size={20}
|
||||
context="menu-item"
|
||||
/>
|
||||
)}
|
||||
</ConvoLink>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -203,7 +242,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
|||
// but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete.
|
||||
// aria-hidden={!(isPopoverActive || isActiveConvo)}
|
||||
>
|
||||
{!renaming && <ConvoOptions {...convoOptionsProps} />}
|
||||
{/* Only render ConvoOptions when user interacts (hover/focus) or for active conversation */}
|
||||
{!renaming && (hasInteracted || isActiveConvo) && <ConvoOptions {...convoOptionsProps} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -122,11 +122,9 @@ function ConvoOptions({
|
|||
if (!convoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, convoId]);
|
||||
const thread_id = messages?.[messages.length - 1]?.thread_id;
|
||||
const endpoint = messages?.[messages.length - 1]?.endpoint;
|
||||
|
||||
deleteMutation.mutate({ conversationId: convoId, thread_id, endpoint, source: 'button' });
|
||||
},
|
||||
[conversationId, deleteMutation, queryClient],
|
||||
|
|
@ -148,12 +146,10 @@ function ConvoOptions({
|
|||
setTimeout(() => {
|
||||
setAnnouncement('');
|
||||
}, 10000);
|
||||
|
||||
if (currentConvoId === convoId || currentConvoId === 'new') {
|
||||
newConversation();
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
|
||||
retainView();
|
||||
setIsPopoverActive(false);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
|||
import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client';
|
||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||
import SharedLinkButton from './SharedLinkButton';
|
||||
import { cn } from '~/utils';
|
||||
import { buildShareLinkUrl, cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ShareButton({
|
||||
|
|
@ -40,8 +40,7 @@ export default function ShareButton({
|
|||
|
||||
useEffect(() => {
|
||||
if (share?.shareId !== undefined) {
|
||||
const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`;
|
||||
setSharedLink(link);
|
||||
setSharedLink(buildShareLinkUrl(share.shareId));
|
||||
}
|
||||
}, [share]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
useDeleteSharedLinkMutation,
|
||||
} from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { buildShareLinkUrl } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function SharedLinkButton({
|
||||
|
|
@ -85,9 +86,7 @@ export default function SharedLinkButton({
|
|||
},
|
||||
});
|
||||
|
||||
const generateShareLink = useCallback((shareId: string) => {
|
||||
return `${window.location.protocol}//${window.location.host}/share/${shareId}`;
|
||||
}, []);
|
||||
const generateShareLink = (shareId: string) => buildShareLinkUrl(shareId);
|
||||
|
||||
const updateSharedLink = async () => {
|
||||
if (!shareId) {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,14 @@ export default function MCPConfigDialog({
|
|||
});
|
||||
}, [serverStatus, serverName, localize]);
|
||||
|
||||
// Helper function to render status badge based on connection state
|
||||
/**
|
||||
* Render status badge with unified color system:
|
||||
* - Blue: Connecting/In-progress
|
||||
* - Amber: Needs action (OAuth required)
|
||||
* - Gray: Disconnected (neutral/inactive)
|
||||
* - Green: Connected (success)
|
||||
* - Red: Error
|
||||
*/
|
||||
const renderStatusBadge = () => {
|
||||
if (!serverStatus) {
|
||||
return null;
|
||||
|
|
@ -79,46 +86,51 @@ export default function MCPConfigDialog({
|
|||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
// Connecting: blue (in progress)
|
||||
if (connectionState === 'connecting') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Spinner className="h-3 w-3" />
|
||||
<Spinner className="size-3" />
|
||||
<span>{localize('com_ui_connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Disconnected: check if needs action
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
// Needs OAuth: amber (requires action)
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-950 dark:text-amber-400">
|
||||
<KeyRound className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_oauth')}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-600 dark:bg-orange-950 dark:text-orange-400">
|
||||
<PlugZap className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_offline')}</span>
|
||||
<KeyRound className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_needs_auth')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Simply disconnected: gray (neutral)
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||
<PlugZap className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_disconnected')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error: red
|
||||
if (connectionState === 'error') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600 dark:bg-red-950 dark:text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
<AlertTriangle className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected: green
|
||||
if (connectionState === 'connected') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-950 dark:text-green-400">
|
||||
<div className="size-1.5 rounded-full bg-green-500" />
|
||||
<span>{localize('com_ui_active')}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
113
client/src/components/MCP/MCPServerMenuItem.tsx
Normal file
113
client/src/components/MCP/MCPServerMenuItem.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { MCPIcon } from '@librechat/client';
|
||||
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
|
||||
import MCPServerStatusIcon from './MCPServerStatusIcon';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusTextKey,
|
||||
shouldShowActionButton,
|
||||
type ConnectionStatusMap,
|
||||
} from './mcpServerUtils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPServerMenuItemProps {
|
||||
server: MCPServerDefinition;
|
||||
isSelected: boolean;
|
||||
connectionStatus?: ConnectionStatusMap;
|
||||
isInitializing?: (serverName: string) => boolean;
|
||||
statusIconProps?: MCPServerStatusIconProps | null;
|
||||
onToggle: (serverName: string) => void;
|
||||
}
|
||||
|
||||
export default function MCPServerMenuItem({
|
||||
server,
|
||||
isSelected,
|
||||
connectionStatus,
|
||||
isInitializing,
|
||||
statusIconProps,
|
||||
onToggle,
|
||||
}: MCPServerMenuItemProps) {
|
||||
const localize = useLocalize();
|
||||
const displayName = server.config?.title || server.serverName;
|
||||
const statusColor = getStatusColor(server.serverName, connectionStatus, isInitializing);
|
||||
const statusTextKey = getStatusTextKey(server.serverName, connectionStatus, isInitializing);
|
||||
const statusText = localize(statusTextKey as Parameters<typeof localize>[0]);
|
||||
const showActionButton = shouldShowActionButton(statusIconProps);
|
||||
|
||||
// Include status in aria-label so screen readers announce it
|
||||
const accessibleLabel = `${displayName}, ${statusText}`;
|
||||
|
||||
return (
|
||||
<Ariakit.MenuItemCheckbox
|
||||
hideOnClick={false}
|
||||
name="mcp-servers"
|
||||
value={server.serverName}
|
||||
checked={isSelected}
|
||||
setValueOnChange={false}
|
||||
onChange={() => onToggle(server.serverName)}
|
||||
aria-label={accessibleLabel}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2',
|
||||
'outline-none transition-all duration-150',
|
||||
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||
isSelected && 'bg-surface-active-alt',
|
||||
)}
|
||||
>
|
||||
{/* Server Icon with Status Dot */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{server.config?.iconPath ? (
|
||||
<img
|
||||
src={server.config.iconPath}
|
||||
className="h-8 w-8 rounded-lg object-cover"
|
||||
alt={displayName}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-tertiary">
|
||||
<MCPIcon className="h-5 w-5 text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
{/* Status dot - decorative, status is announced via aria-label on MenuItem */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-secondary',
|
||||
statusColor,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
|
||||
</div>
|
||||
{server.config?.description && (
|
||||
<p className="truncate text-xs text-text-secondary">{server.config.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button - only show when actionable */}
|
||||
{showActionButton && statusIconProps && (
|
||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<MCPServerStatusIcon {...statusIconProps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Indicator - purely visual, state conveyed by aria-checked on MenuItem */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border-xheavy bg-transparent',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-4 w-4" />}
|
||||
</span>
|
||||
</Ariakit.MenuItemCheckbox>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import { Spinner, TooltipAnchor } from '@librechat/client';
|
||||
import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X, CircleCheck } from 'lucide-react';
|
||||
import type { MCPServerStatus, TPlugin } from 'librechat-data-provider';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { PlugZap, SlidersHorizontal, X } from 'lucide-react';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
let localize: ReturnType<typeof useLocalize>;
|
||||
|
||||
|
|
@ -16,34 +17,48 @@ interface InitializingStatusProps extends StatusIconProps {
|
|||
canCancel: boolean;
|
||||
}
|
||||
|
||||
interface MCPServerStatusIconProps {
|
||||
export interface MCPServerStatusIconProps {
|
||||
serverName: string;
|
||||
serverStatus?: MCPServerStatus;
|
||||
tool?: TPlugin;
|
||||
onConfigClick: (e: React.MouseEvent) => void;
|
||||
isInitializing: boolean;
|
||||
canCancel: boolean;
|
||||
onCancel: (e: React.MouseEvent) => void;
|
||||
hasCustomUserVars?: boolean;
|
||||
/** When true, renders as a small status dot for compact layouts */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the appropriate status icon for an MCP server based on its state
|
||||
* Renders the appropriate status icon for an MCP server based on its state.
|
||||
*
|
||||
* Unified icon system:
|
||||
* - PlugZap: Connect/Authenticate (for disconnected servers that need connection)
|
||||
* - SlidersHorizontal: Configure (for connected servers with custom vars)
|
||||
* - Spinner: Loading state (during connection)
|
||||
* - X: Cancel (during OAuth flow, shown on hover over spinner)
|
||||
*/
|
||||
export default function MCPServerStatusIcon({
|
||||
serverName,
|
||||
serverStatus,
|
||||
tool,
|
||||
onConfigClick,
|
||||
isInitializing,
|
||||
canCancel,
|
||||
onCancel,
|
||||
hasCustomUserVars = false,
|
||||
compact = false,
|
||||
}: MCPServerStatusIconProps) {
|
||||
localize = useLocalize();
|
||||
|
||||
// Compact mode: render as a small status dot
|
||||
if (compact) {
|
||||
return <CompactStatusDot serverStatus={serverStatus} isInitializing={isInitializing} />;
|
||||
}
|
||||
|
||||
// Loading state: show spinner (with cancel option if available)
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<InitializingStatusIcon
|
||||
<LoadingStatusIcon
|
||||
serverName={serverName}
|
||||
onConfigClick={onConfigClick}
|
||||
onCancel={onCancel}
|
||||
|
|
@ -56,178 +71,126 @@ export default function MCPServerStatusIcon({
|
|||
return null;
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
const { connectionState } = serverStatus;
|
||||
|
||||
// Connecting: show spinner only (no action available)
|
||||
if (connectionState === 'connecting') {
|
||||
return <ConnectingStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
return <ConnectingSpinner serverName={serverName} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
return <DisconnectedOAuthStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
return <DisconnectedStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
// Disconnected or Error: show connect button (PlugZap icon)
|
||||
if (connectionState === 'disconnected' || connectionState === 'error') {
|
||||
return <ConnectButton serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'error') {
|
||||
return <ErrorStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
// Only show config button if there are customUserVars to configure
|
||||
if (hasCustomUserVars) {
|
||||
const isAuthenticated = tool?.authenticated || requiresOAuth;
|
||||
return (
|
||||
<AuthenticatedStatusIcon
|
||||
serverName={serverName}
|
||||
onConfigClick={onConfigClick}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConnectedStatusIcon
|
||||
serverName={serverName}
|
||||
requiresOAuth={requiresOAuth}
|
||||
onConfigClick={onConfigClick}
|
||||
/>
|
||||
);
|
||||
// Connected: only show config button if there are custom vars to configure
|
||||
if (connectionState === 'connected' && hasCustomUserVars) {
|
||||
return <ConfigureButton serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
// Connected without custom vars: no action needed, status shown via dot
|
||||
return null;
|
||||
}
|
||||
|
||||
function InitializingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
|
||||
interface CompactStatusDotProps {
|
||||
serverStatus?: MCPServerStatus;
|
||||
isInitializing: boolean;
|
||||
}
|
||||
|
||||
function CompactStatusDot({ serverStatus, isInitializing }: CompactStatusDotProps) {
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex size-3.5 items-center justify-center rounded-full border-2 border-surface-secondary bg-blue-500">
|
||||
<div className="size-1.5 animate-pulse rounded-full bg-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverStatus) {
|
||||
return <div className="size-3 rounded-full border-2 border-surface-secondary bg-gray-400" />;
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
let colorClass = 'bg-gray-400';
|
||||
if (connectionState === 'connected') {
|
||||
colorClass = 'bg-green-500';
|
||||
} else if (connectionState === 'connecting') {
|
||||
colorClass = 'bg-blue-500';
|
||||
} else if (connectionState === 'error') {
|
||||
colorClass = 'bg-red-500';
|
||||
} else if (connectionState === 'disconnected' && requiresOAuth) {
|
||||
colorClass = 'bg-amber-500';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('size-3 rounded-full border-2 border-surface-secondary', colorClass)} />
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
|
||||
if (canCancel) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="group flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
className="group flex size-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
title={localize('com_ui_cancel')}
|
||||
>
|
||||
<div className="relative h-4 w-4">
|
||||
<Spinner className="h-4 w-4 group-hover:opacity-0" />
|
||||
<X className="absolute inset-0 h-4 w-4 text-red-500 opacity-0 group-hover:opacity-100" />
|
||||
<div className="relative size-4">
|
||||
<Spinner className="size-4 text-text-primary group-hover:opacity-0" />
|
||||
<X className="absolute inset-0 size-4 text-red-500 opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
|
||||
<div className="flex size-6 items-center justify-center rounded p-1">
|
||||
<Spinner
|
||||
className="h-4 w-4"
|
||||
className="size-4 text-text-primary"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectingStatusIcon({ serverName }: StatusIconProps) {
|
||||
function ConnectingSpinner({ serverName }: { serverName: string }) {
|
||||
return (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
|
||||
<div className="flex size-6 items-center justify-center rounded p-1">
|
||||
<Spinner
|
||||
className="h-4 w-4"
|
||||
className="size-4 text-text-primary"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
/** Connect button - shown for disconnected/error states. Uses PlugZap icon. */
|
||||
function ConnectButton({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_connect_server', { 0: serverName })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
<PlugZap className="size-4 text-text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
/** Configure button - shown for connected servers with custom vars. Uses SlidersHorizontal icon. */
|
||||
function ConfigureButton({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<PlugZap className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
<SlidersHorizontal className="size-4 text-text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthenticatedStatusProps extends StatusIconProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
function AuthenticatedStatusIcon({
|
||||
serverName,
|
||||
onConfigClick,
|
||||
isAuthenticated,
|
||||
}: AuthenticatedStatusProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<SettingsIcon
|
||||
className={`h-4 w-4 ${isAuthenticated ? 'text-green-500' : 'text-gray-400'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectedStatusProps {
|
||||
serverName: string;
|
||||
requiresOAuth?: boolean;
|
||||
onConfigClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) {
|
||||
if (requiresOAuth) {
|
||||
return (
|
||||
<TooltipAnchor
|
||||
role="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
description={localize('com_nav_mcp_status_connected')}
|
||||
side="top"
|
||||
>
|
||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||
</TooltipAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1"
|
||||
description={localize('com_nav_mcp_status_connected')}
|
||||
side="top"
|
||||
>
|
||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||
</TooltipAnchor>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
101
client/src/components/MCP/StackedMCPIcons.tsx
Normal file
101
client/src/components/MCP/StackedMCPIcons.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useMemo } from 'react';
|
||||
import { MCPIcon } from '@librechat/client';
|
||||
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
import { getSelectedServerIcons } from './mcpServerUtils';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface StackedMCPIconsProps {
|
||||
selectedServers: MCPServerDefinition[];
|
||||
maxIcons?: number;
|
||||
iconSize?: 'sm' | 'md';
|
||||
variant?: 'default' | 'submenu';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
icon: 'h-[18px] w-[18px]',
|
||||
container: 'h-[22px] w-[22px]',
|
||||
overlap: '-ml-2.5',
|
||||
},
|
||||
md: {
|
||||
icon: 'h-5 w-5',
|
||||
container: 'h-6 w-6',
|
||||
overlap: '-ml-3',
|
||||
},
|
||||
};
|
||||
|
||||
const variantConfig = {
|
||||
default: {
|
||||
border: 'border-border-medium',
|
||||
bg: 'bg-surface-secondary',
|
||||
},
|
||||
submenu: {
|
||||
border: 'border-surface-primary',
|
||||
bg: 'bg-surface-primary',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StackedMCPIcons({
|
||||
selectedServers,
|
||||
maxIcons = 3,
|
||||
iconSize = 'md',
|
||||
variant = 'default',
|
||||
}: StackedMCPIconsProps) {
|
||||
const { icons, overflowCount } = useMemo(
|
||||
() => getSelectedServerIcons(selectedServers, maxIcons),
|
||||
[selectedServers, maxIcons],
|
||||
);
|
||||
|
||||
if (icons.length === 0) {
|
||||
return (
|
||||
<MCPIcon
|
||||
aria-hidden="true"
|
||||
className={cn('flex-shrink-0 text-text-primary', sizeConfig.md.icon)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sizes = sizeConfig[iconSize];
|
||||
const colors = variantConfig[variant];
|
||||
|
||||
return (
|
||||
<div className="flex items-center" aria-hidden="true">
|
||||
{icons.map((icon, index) => (
|
||||
<div
|
||||
key={icon.key}
|
||||
title={icon.displayName}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-full border',
|
||||
colors.border,
|
||||
colors.bg,
|
||||
sizes.container,
|
||||
index > 0 && sizes.overlap,
|
||||
)}
|
||||
style={{ zIndex: icons.length - index }}
|
||||
>
|
||||
{icon.iconPath ? (
|
||||
<img
|
||||
src={icon.iconPath}
|
||||
alt={icon.displayName}
|
||||
className={cn('rounded-full object-cover', sizes.icon)}
|
||||
/>
|
||||
) : (
|
||||
<MCPIcon className={cn('text-text-primary', sizes.icon)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-full border border-surface-primary bg-surface-tertiary text-xs font-medium text-text-secondary',
|
||||
sizes.container,
|
||||
sizes.overlap,
|
||||
)}
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
client/src/components/MCP/mcpServerUtils.ts
Normal file
196
client/src/components/MCP/mcpServerUtils.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
|
||||
|
||||
export type { MCPServerStatus };
|
||||
|
||||
export interface SelectedIconInfo {
|
||||
key: string;
|
||||
serverName: string;
|
||||
iconPath: string | null;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatusMap = Record<string, MCPServerStatus>;
|
||||
|
||||
/**
|
||||
* Generates a list of icons to display for selected MCP servers.
|
||||
* - Custom icons are shown individually
|
||||
* - Multiple default icons are consolidated into one
|
||||
* - Limited to maxIcons with overflow count
|
||||
*/
|
||||
export function getSelectedServerIcons(
|
||||
selectedServers: MCPServerDefinition[],
|
||||
maxIcons: number = 3,
|
||||
): { icons: SelectedIconInfo[]; overflowCount: number; defaultServerNames: string[] } {
|
||||
const customIcons: SelectedIconInfo[] = [];
|
||||
const defaultServerNames: string[] = [];
|
||||
|
||||
for (const server of selectedServers) {
|
||||
const displayName = server.config?.title || server.serverName;
|
||||
if (server.config?.iconPath) {
|
||||
customIcons.push({
|
||||
key: server.serverName,
|
||||
serverName: server.serverName,
|
||||
iconPath: server.config.iconPath,
|
||||
displayName,
|
||||
});
|
||||
} else {
|
||||
defaultServerNames.push(server.serverName);
|
||||
}
|
||||
}
|
||||
|
||||
// Add one default icon entry if any server uses default icon
|
||||
// Custom icons are prioritized first, default icon comes last
|
||||
const allIcons: SelectedIconInfo[] =
|
||||
defaultServerNames.length > 0
|
||||
? [
|
||||
...customIcons,
|
||||
{
|
||||
key: '_default_',
|
||||
serverName: defaultServerNames[0],
|
||||
iconPath: null,
|
||||
displayName: 'MCP',
|
||||
},
|
||||
]
|
||||
: customIcons;
|
||||
|
||||
const visibleIcons = allIcons.slice(0, maxIcons);
|
||||
const overflowCount = Math.max(0, allIcons.length - maxIcons);
|
||||
|
||||
return { icons: visibleIcons, overflowCount, defaultServerNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified status color system following UX best practices:
|
||||
* - Green: Connected/Active (success)
|
||||
* - Blue: Connecting/In-progress (processing)
|
||||
* - Amber: Needs user action (OAuth required, config missing)
|
||||
* - Gray: Disconnected/Inactive (neutral - server is simply off)
|
||||
* - Red: Error (failed, needs retry)
|
||||
*
|
||||
* Key insight: "Disconnected" is neutral (gray), not a warning.
|
||||
* Amber is reserved for states requiring user intervention.
|
||||
*/
|
||||
export function getStatusColor(
|
||||
serverName: string,
|
||||
connectionStatus?: ConnectionStatusMap,
|
||||
isInitializing?: (serverName: string) => boolean,
|
||||
): string {
|
||||
// In-progress states: blue
|
||||
if (isInitializing?.(serverName)) {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
const status = connectionStatus?.[serverName];
|
||||
if (!status) {
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = status;
|
||||
|
||||
// Connecting: blue (in progress)
|
||||
if (connectionState === 'connecting') {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
// Connected: green (success)
|
||||
if (connectionState === 'connected') {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// Error: red
|
||||
if (connectionState === 'error') {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
// Disconnected: check if needs action or just inactive
|
||||
if (connectionState === 'disconnected') {
|
||||
// Needs OAuth = amber (requires user action)
|
||||
if (requiresOAuth) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
// Simply disconnected = gray (neutral/inactive)
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
export function getStatusTextKey(
|
||||
serverName: string,
|
||||
connectionStatus?: ConnectionStatusMap,
|
||||
isInitializing?: (serverName: string) => boolean,
|
||||
): string {
|
||||
if (isInitializing?.(serverName)) {
|
||||
return 'com_nav_mcp_status_initializing';
|
||||
}
|
||||
|
||||
const status = connectionStatus?.[serverName];
|
||||
if (!status) {
|
||||
return 'com_nav_mcp_status_unknown';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = status;
|
||||
|
||||
// Special case: disconnected but needs OAuth shows different text
|
||||
if (connectionState === 'disconnected' && requiresOAuth) {
|
||||
return 'com_nav_mcp_status_needs_auth';
|
||||
}
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
connected: 'com_nav_mcp_status_connected',
|
||||
connecting: 'com_nav_mcp_status_connecting',
|
||||
disconnected: 'com_nav_mcp_status_disconnected',
|
||||
error: 'com_nav_mcp_status_error',
|
||||
};
|
||||
|
||||
return keyMap[connectionState] || 'com_nav_mcp_status_unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a server requires user action to connect.
|
||||
* Used to show action buttons and amber status color.
|
||||
*/
|
||||
export function serverNeedsAction(
|
||||
serverStatus?: MCPServerStatus,
|
||||
_hasCustomUserVars?: boolean,
|
||||
): boolean {
|
||||
if (!serverStatus) return false;
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
// Needs OAuth authentication
|
||||
if (connectionState === 'disconnected' && requiresOAuth) return true;
|
||||
|
||||
// Has error - needs retry
|
||||
if (connectionState === 'error') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an action button should be shown for a server status.
|
||||
* Returns true only when the button would be actionable (not just informational).
|
||||
*/
|
||||
export function shouldShowActionButton(statusIconProps?: MCPServerStatusIconProps | null): boolean {
|
||||
if (!statusIconProps) return false;
|
||||
|
||||
const { serverStatus, canCancel, hasCustomUserVars, isInitializing } = statusIconProps;
|
||||
|
||||
// Show cancel button during OAuth flow
|
||||
if (isInitializing && canCancel) return true;
|
||||
// Don't show spinner-only state (no action available)
|
||||
if (isInitializing) return false;
|
||||
|
||||
if (!serverStatus) return false;
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
// Show for disconnected/error (can reconnect/configure)
|
||||
if (connectionState === 'disconnected' || connectionState === 'error') return true;
|
||||
// Don't show connecting spinner (no action)
|
||||
if (connectionState === 'connecting') return false;
|
||||
// Connected: only show if there's config to manage
|
||||
if (connectionState === 'connected') return hasCustomUserVars || requiresOAuth;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useRef, useState, useMemo, useEffect } from 'react';
|
||||
import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
|
|
@ -19,6 +19,10 @@ type CodeBlockProps = Pick<
|
|||
classProp?: string;
|
||||
};
|
||||
|
||||
interface FloatingCodeBarProps extends CodeBarProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
const CodeBar: React.FC<CodeBarProps> = React.memo(
|
||||
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -51,16 +55,14 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
|
|||
}
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark className="h-[18px] w-[18px]" />
|
||||
{error === true ? '' : localize('com_ui_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
{error === true ? '' : localize('com_ui_copy_code')}
|
||||
</>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{error !== true && (
|
||||
<span className="relative">
|
||||
<span className="invisible">{localize('com_ui_copy_code')}</span>
|
||||
<span className="absolute inset-0 flex items-center">
|
||||
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -70,6 +72,75 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
|
|||
},
|
||||
);
|
||||
|
||||
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
|
||||
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const codeString = codeRef.current?.textContent;
|
||||
if (codeString != null) {
|
||||
const wasFocused = document.activeElement === copyButtonRef.current;
|
||||
setIsCopied(true);
|
||||
copy(codeString.trim(), { format: 'text/plain' });
|
||||
if (wasFocused) {
|
||||
requestAnimationFrame(() => {
|
||||
copyButtonRef.current?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const focusedElement = document.activeElement as HTMLElement | null;
|
||||
setIsCopied(false);
|
||||
requestAnimationFrame(() => {
|
||||
focusedElement?.focus();
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
}, [codeRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
|
||||
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
{plugin === true ? (
|
||||
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
|
||||
) : (
|
||||
<>
|
||||
{allowExecution === true && (
|
||||
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
|
||||
)}
|
||||
<button
|
||||
ref={copyButtonRef}
|
||||
type="button"
|
||||
tabIndex={isVisible ? 0 : -1}
|
||||
className={cn(
|
||||
'flex gap-2 rounded px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
|
||||
error === true ? 'h-4 w-4 items-start text-white/50' : '',
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{error !== true && (
|
||||
<span className="relative">
|
||||
<span className="invisible">{localize('com_ui_copy_code')}</span>
|
||||
<span className="absolute inset-0 flex items-center">
|
||||
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||
lang,
|
||||
blockIndex,
|
||||
|
|
@ -80,6 +151,8 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
error,
|
||||
}) => {
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isBarVisible, setIsBarVisible] = useState(false);
|
||||
const toolCallsMap = useToolCallsMapContext();
|
||||
const { messageId, partIndex } = useMessageContext();
|
||||
const key = allowExecution
|
||||
|
|
@ -97,6 +170,29 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
}
|
||||
}, [fetchedToolCalls]);
|
||||
|
||||
// Handle focus within the container (for keyboard navigation)
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsBarVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback((e: React.FocusEvent) => {
|
||||
// Check if focus is moving to another element within the container
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsBarVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Only hide if no element inside has focus
|
||||
if (!containerRef.current?.contains(document.activeElement)) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]);
|
||||
|
||||
const next = () => {
|
||||
|
|
@ -118,7 +214,14 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
const language = isNonCode ? 'json' : lang;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<CodeBar
|
||||
lang={lang}
|
||||
error={error}
|
||||
|
|
@ -137,6 +240,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
{codeChildren}
|
||||
</code>
|
||||
</div>
|
||||
<FloatingCodeBar
|
||||
lang={lang}
|
||||
error={error}
|
||||
codeRef={codeRef}
|
||||
blockIndex={blockIndex}
|
||||
plugin={plugin === true}
|
||||
allowExecution={allowExecution}
|
||||
isVisible={isBarVisible}
|
||||
/>
|
||||
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
|
||||
<>
|
||||
<div className="bg-gray-700 p-4 text-xs">
|
||||
|
|
|
|||
744
client/src/components/Messages/Content/Mermaid.tsx
Normal file
744
client/src/components/Messages/Content/Mermaid.tsx
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
import React, { useEffect, useMemo, useState, useRef, useCallback, memo } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import {
|
||||
X,
|
||||
ZoomIn,
|
||||
Expand,
|
||||
ZoomOut,
|
||||
ChevronUp,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
Clipboard,
|
||||
CheckMark,
|
||||
OGDialogClose,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
import { useLocalize, useDebouncedMermaid } from '~/hooks';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface MermaidProps {
|
||||
/** Mermaid diagram content */
|
||||
children: string;
|
||||
/** Unique identifier */
|
||||
id?: string;
|
||||
/** Custom theme */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
|
||||
const localize = useLocalize();
|
||||
const [blobUrl, setBlobUrl] = useState<string>('');
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
// Separate showCode state for dialog to avoid re-renders
|
||||
const [dialogShowCode, setDialogShowCode] = useState(false);
|
||||
const lastValidSvgRef = useRef<string | null>(null);
|
||||
const expandButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Zoom and pan state
|
||||
const [zoom, setZoom] = useState(1);
|
||||
// Dialog zoom and pan state (separate from inline view)
|
||||
const [dialogZoom, setDialogZoom] = useState(1);
|
||||
const [dialogPan, setDialogPan] = useState({ x: 0, y: 0 });
|
||||
const [isDialogPanning, setIsDialogPanning] = useState(false);
|
||||
const dialogPanStartRef = useRef({ x: 0, y: 0 });
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const panStartRef = useRef({ x: 0, y: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const streamingCodeRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Get SVG from debounced hook (handles streaming gracefully)
|
||||
const { svg, isLoading, error } = useDebouncedMermaid({
|
||||
content: children,
|
||||
id,
|
||||
theme,
|
||||
key: retryCount,
|
||||
});
|
||||
|
||||
// Auto-scroll streaming code to bottom
|
||||
useEffect(() => {
|
||||
if (isLoading && streamingCodeRef.current) {
|
||||
streamingCodeRef.current.scrollTop = streamingCodeRef.current.scrollHeight;
|
||||
}
|
||||
}, [children, isLoading]);
|
||||
|
||||
// Store last valid SVG for showing during updates
|
||||
useEffect(() => {
|
||||
if (svg) {
|
||||
lastValidSvgRef.current = svg;
|
||||
}
|
||||
}, [svg]);
|
||||
|
||||
// Process SVG and create blob URL
|
||||
const processedSvg = useMemo(() => {
|
||||
if (!svg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let finalSvg = svg;
|
||||
|
||||
// Firefox fix: Ensure viewBox is set correctly
|
||||
if (!svg.includes('viewBox') && svg.includes('height=') && svg.includes('width=')) {
|
||||
const widthMatch = svg.match(/width="(\d+)"/);
|
||||
const heightMatch = svg.match(/height="(\d+)"/);
|
||||
|
||||
if (widthMatch && heightMatch) {
|
||||
const width = widthMatch[1];
|
||||
const height = heightMatch[1];
|
||||
finalSvg = svg.replace('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure SVG has proper XML namespace
|
||||
if (!finalSvg.includes('xmlns')) {
|
||||
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
|
||||
return finalSvg;
|
||||
}, [svg]);
|
||||
|
||||
// Create blob URL for the SVG
|
||||
useEffect(() => {
|
||||
if (!processedSvg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([processedSvg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
|
||||
return () => {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
}, [processedSvg]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(children.trim(), { format: 'text/plain' });
|
||||
setIsCopied(true);
|
||||
requestAnimationFrame(() => {
|
||||
copyButtonRef.current?.focus();
|
||||
});
|
||||
setTimeout(() => {
|
||||
// Save currently focused element before state update causes re-render
|
||||
const focusedElement = document.activeElement as HTMLElement | null;
|
||||
setIsCopied(false);
|
||||
// Restore focus to whatever was focused (React re-render may have disrupted it)
|
||||
requestAnimationFrame(() => {
|
||||
focusedElement?.focus();
|
||||
});
|
||||
}, 3000);
|
||||
}, [children]);
|
||||
|
||||
const [isDialogCopied, setIsDialogCopied] = useState(false);
|
||||
const handleDialogCopy = useCallback(() => {
|
||||
copy(children.trim(), { format: 'text/plain' });
|
||||
setIsDialogCopied(true);
|
||||
requestAnimationFrame(() => {
|
||||
dialogCopyButtonRef.current?.focus();
|
||||
});
|
||||
setTimeout(() => {
|
||||
setIsDialogCopied(false);
|
||||
requestAnimationFrame(() => {
|
||||
dialogCopyButtonRef.current?.focus();
|
||||
});
|
||||
}, 3000);
|
||||
}, [children]);
|
||||
|
||||
// Zoom controls copy with focus restoration
|
||||
const [isZoomCopied, setIsZoomCopied] = useState(false);
|
||||
const handleZoomCopy = useCallback(() => {
|
||||
copy(children.trim(), { format: 'text/plain' });
|
||||
setIsZoomCopied(true);
|
||||
requestAnimationFrame(() => {
|
||||
zoomCopyButtonRef.current?.focus();
|
||||
});
|
||||
setTimeout(() => {
|
||||
setIsZoomCopied(false);
|
||||
requestAnimationFrame(() => {
|
||||
zoomCopyButtonRef.current?.focus();
|
||||
});
|
||||
}, 3000);
|
||||
}, [children]);
|
||||
|
||||
// Dialog zoom controls copy
|
||||
const handleDialogZoomCopy = useCallback(() => {
|
||||
copy(children.trim(), { format: 'text/plain' });
|
||||
requestAnimationFrame(() => {
|
||||
dialogZoomCopyButtonRef.current?.focus();
|
||||
});
|
||||
}, [children]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// Toggle code with focus restoration
|
||||
const handleToggleCode = useCallback(() => {
|
||||
setShowCode((prev) => !prev);
|
||||
requestAnimationFrame(() => {
|
||||
showCodeButtonRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle dialog code with focus restoration
|
||||
const handleToggleDialogCode = useCallback(() => {
|
||||
setDialogShowCode((prev) => !prev);
|
||||
requestAnimationFrame(() => {
|
||||
dialogShowCodeButtonRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Zoom handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
// Dialog zoom handlers
|
||||
const handleDialogZoomIn = useCallback(() => {
|
||||
setDialogZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleDialogZoomOut = useCallback(() => {
|
||||
setDialogZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleDialogResetZoom = useCallback(() => {
|
||||
setDialogZoom(1);
|
||||
setDialogPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const handleDialogWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
setDialogZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDialogMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isButton = target.tagName === 'BUTTON' || target.closest('button');
|
||||
if (e.button === 0 && !isButton) {
|
||||
setIsDialogPanning(true);
|
||||
dialogPanStartRef.current = { x: e.clientX - dialogPan.x, y: e.clientY - dialogPan.y };
|
||||
}
|
||||
},
|
||||
[dialogPan],
|
||||
);
|
||||
|
||||
const handleDialogMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isDialogPanning) {
|
||||
setDialogPan({
|
||||
x: e.clientX - dialogPanStartRef.current.x,
|
||||
y: e.clientY - dialogPanStartRef.current.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDialogPanning],
|
||||
);
|
||||
|
||||
const handleDialogMouseUp = useCallback(() => {
|
||||
setIsDialogPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleDialogMouseLeave = useCallback(() => {
|
||||
setIsDialogPanning(false);
|
||||
}, []);
|
||||
|
||||
// Mouse wheel zoom
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Pan handlers
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only start panning on left click and not on buttons/icons inside buttons
|
||||
const target = e.target as HTMLElement;
|
||||
const isButton = target.tagName === 'BUTTON' || target.closest('button');
|
||||
if (e.button === 0 && !isButton) {
|
||||
setIsPanning(true);
|
||||
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
}
|
||||
},
|
||||
[pan],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
setPan({
|
||||
x: e.clientX - panStartRef.current.x,
|
||||
y: e.clientY - panStartRef.current.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPanning],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Header component (shared across states)
|
||||
const Header = ({
|
||||
showActions = false,
|
||||
showExpandButton = false,
|
||||
}: {
|
||||
showActions?: boolean;
|
||||
showExpandButton?: boolean;
|
||||
}) => (
|
||||
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span>{localize('com_ui_mermaid')}</span>
|
||||
{showActions && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{showExpandButton && (
|
||||
<Button
|
||||
ref={expandButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={() => {
|
||||
setDialogShowCode(false);
|
||||
setDialogZoom(1);
|
||||
setDialogPan({ x: 0, y: 0 });
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_expand')}
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
{localize('com_ui_expand')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ref={showCodeButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleToggleCode}
|
||||
>
|
||||
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={copyButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{localize('com_ui_copy_code')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Zoom controls - inline JSX to avoid stale closure issues
|
||||
const zoomControls = (
|
||||
<div className="absolute bottom-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleZoomOut();
|
||||
}}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleZoomIn();
|
||||
}}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetZoom();
|
||||
}}
|
||||
disabled={zoom === 1 && pan.x === 0 && pan.y === 0}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
ref={zoomCopyButtonRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleZoomCopy();
|
||||
}}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
|
||||
title={localize('com_ui_copy_code')}
|
||||
>
|
||||
{isZoomCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Dialog zoom controls
|
||||
const dialogZoomControls = (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogZoomOut();
|
||||
}}
|
||||
disabled={dialogZoom <= MIN_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
|
||||
{Math.round(dialogZoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogZoomIn();
|
||||
}}
|
||||
disabled={dialogZoom >= MAX_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogResetZoom();
|
||||
}}
|
||||
disabled={dialogZoom === 1 && dialogPan.x === 0 && dialogPan.y === 0}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
ref={dialogZoomCopyButtonRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogZoomCopy();
|
||||
}}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
|
||||
title={localize('com_ui_copy_code')}
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Full-screen dialog - rendered inline, not as function component to avoid recreation
|
||||
const expandedDialog = (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} triggerRef={expandButtonRef}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] gap-0 overflow-hidden border-border-light bg-surface-primary-alt p-0"
|
||||
>
|
||||
<OGDialogTitle className="flex h-10 items-center justify-between bg-gray-700 px-4 font-sans text-xs text-gray-200">
|
||||
<span>{localize('com_ui_mermaid')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
ref={dialogShowCodeButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleToggleDialogCode}
|
||||
>
|
||||
{dialogShowCode ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
{dialogShowCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={dialogCopyButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleDialogCopy}
|
||||
>
|
||||
{isDialogCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{localize('com_ui_copy_code')}
|
||||
</Button>
|
||||
<OGDialogClose className="rounded-sm p-1 text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{localize('com_ui_close')}</span>
|
||||
</OGDialogClose>
|
||||
</div>
|
||||
</OGDialogTitle>
|
||||
{dialogShowCode && (
|
||||
<div className="border-b border-border-medium bg-surface-secondary p-4">
|
||||
<pre className="max-h-[150px] overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex-1 overflow-hidden p-4',
|
||||
'bg-surface-primary-alt',
|
||||
isDialogPanning ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
style={{ height: dialogShowCode ? 'calc(85vh - 200px)' : 'calc(85vh - 50px)' }}
|
||||
onWheel={handleDialogWheel}
|
||||
onMouseDown={handleDialogMouseDown}
|
||||
onMouseMove={handleDialogMouseMove}
|
||||
onMouseUp={handleDialogMouseUp}
|
||||
onMouseLeave={handleDialogMouseLeave}
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${dialogPan.x}px, ${dialogPan.y}px)`,
|
||||
transition: isDialogPanning ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt="Mermaid diagram"
|
||||
className="max-h-full max-w-full select-none object-contain"
|
||||
style={{
|
||||
transform: `scale(${dialogZoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{dialogZoomControls}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
|
||||
// Loading state - show last valid diagram with loading indicator, or spinner
|
||||
if (isLoading) {
|
||||
// If we have a previous valid render, show it with a subtle loading indicator
|
||||
if (lastValidSvgRef.current && blobUrl) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<Header showActions />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden p-4',
|
||||
'rounded-b-md bg-surface-primary-alt',
|
||||
isPanning ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
style={{ minHeight: '250px', maxHeight: '600px' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="absolute left-2 top-2 z-10 flex items-center gap-1 rounded border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary">
|
||||
<Spinner className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt="Mermaid diagram"
|
||||
className="max-w-full select-none opacity-70"
|
||||
style={{
|
||||
maxHeight: '500px',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{zoomControls}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No previous render, show streaming code
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<div className="flex items-center gap-2 rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<Spinner className="h-3 w-3 text-gray-200" />
|
||||
<span>{localize('com_ui_mermaid')}</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={streamingCodeRef}
|
||||
className="max-h-[350px] min-h-[150px] overflow-auto whitespace-pre-wrap rounded-b-md bg-surface-primary-alt p-4 font-mono text-xs text-text-secondary"
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<Header showActions />
|
||||
<div className="rounded-b-md border-t border-red-500/30 bg-red-500/10 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-semibold text-red-500 dark:text-red-400">
|
||||
{localize('com_ui_mermaid_failed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{localize('com_ui_retry')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-auto text-xs text-red-600 dark:text-red-300">
|
||||
{error.message}
|
||||
</pre>
|
||||
{showCode && (
|
||||
<div className="mt-4 border-t border-border-medium pt-4">
|
||||
<div className="mb-2 text-xs text-text-secondary">
|
||||
{localize('com_ui_mermaid_source')}
|
||||
</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (!blobUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{expandedDialog}
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<Header showActions showExpandButton />
|
||||
{showCode && (
|
||||
<div className="border-b border-border-medium bg-surface-secondary p-4">
|
||||
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden p-4',
|
||||
'bg-surface-primary-alt',
|
||||
!showCode && 'rounded-b-md',
|
||||
isPanning ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
style={{ minHeight: '250px', maxHeight: '600px' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-[200px] items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt="Mermaid diagram"
|
||||
className="max-w-full select-none"
|
||||
style={{
|
||||
maxHeight: '500px',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{zoomControls}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Mermaid.displayName = 'Mermaid';
|
||||
|
||||
export default Mermaid;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
|
||||
interface MermaidErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
/** The mermaid code to display as fallback */
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface MermaidErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary specifically for Mermaid diagrams.
|
||||
* Falls back to displaying the raw mermaid code if rendering fails.
|
||||
*/
|
||||
class MermaidErrorBoundary extends React.Component<
|
||||
MermaidErrorBoundaryProps,
|
||||
MermaidErrorBoundaryState
|
||||
> {
|
||||
constructor(props: MermaidErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): MermaidErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Mermaid rendering error:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: MermaidErrorBoundaryProps) {
|
||||
// Reset error state if code changes (e.g., user edits the message)
|
||||
if (prevProps.code !== this.props.code && this.state.hasError) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<div className="rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
{'mermaid'}
|
||||
</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-gray-900 p-4 font-mono text-xs text-gray-300">
|
||||
{this.props.code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default MermaidErrorBoundary;
|
||||
|
|
@ -86,7 +86,9 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white')}
|
||||
className={cn(
|
||||
'ml-auto flex gap-2 rounded-sm px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
|
||||
)}
|
||||
onClick={debouncedExecute}
|
||||
disabled={execute.isLoading}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,22 +3,20 @@ import { useAtomValue } from 'jotai';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { useAttachments, useLocalize, useMessageActions } from '~/hooks';
|
||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||
import { cn, getMessageAriaLabel } from '~/utils';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { cn, getMessageAriaLabel, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type ContentRenderProps = {
|
||||
message?: TMessage;
|
||||
isCard?: boolean;
|
||||
isMultiMessage?: boolean;
|
||||
isSubmittingFamily?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
} & Pick<
|
||||
TMessageProps,
|
||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||
|
|
@ -27,14 +25,12 @@ type ContentRenderProps = {
|
|||
const ContentRender = memo(
|
||||
({
|
||||
message: msg,
|
||||
isCard = false,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
currentEditId,
|
||||
isMultiMessage = false,
|
||||
setCurrentEditId,
|
||||
isSubmittingFamily = false,
|
||||
isSubmitting = false,
|
||||
}: ContentRenderProps) => {
|
||||
const localize = useLocalize();
|
||||
const { attachments, searchResults } = useAttachments({
|
||||
|
|
@ -49,18 +45,15 @@ const ContentRender = memo(
|
|||
enterEdit,
|
||||
conversation,
|
||||
messageLabel,
|
||||
isSubmitting,
|
||||
latestMessage,
|
||||
handleContinue,
|
||||
copyToClipboard,
|
||||
setLatestMessage,
|
||||
regenerateMessage,
|
||||
handleFeedback,
|
||||
copyToClipboard,
|
||||
regenerateMessage,
|
||||
} = useMessageActions({
|
||||
message: msg,
|
||||
searchResults,
|
||||
currentEditId,
|
||||
isMultiMessage,
|
||||
setCurrentEditId,
|
||||
});
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
|
|
@ -72,9 +65,10 @@ const ContentRender = memo(
|
|||
!(msg?.children?.length ?? 0) && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
||||
[msg?.children, msg?.depth, latestMessage?.depth],
|
||||
);
|
||||
const hasNoChildren = !(msg?.children?.length ?? 0);
|
||||
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
|
||||
const showCardRender = isLast && !isSubmittingFamily && isCard;
|
||||
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
|
||||
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const iconData: TMessageIcon = useMemo(
|
||||
() => ({
|
||||
|
|
@ -95,36 +89,28 @@ const ContentRender = memo(
|
|||
],
|
||||
);
|
||||
|
||||
const clickHandler = useMemo(
|
||||
() =>
|
||||
showCardRender && !isLatestMessage
|
||||
? () => {
|
||||
logger.log(
|
||||
'latest_message',
|
||||
`Message Card click: Setting ${msg?.messageId} as latest message`,
|
||||
);
|
||||
logger.dir(msg);
|
||||
setLatestMessage(msg!);
|
||||
}
|
||||
: undefined,
|
||||
[showCardRender, isLatestMessage, msg, setLatestMessage],
|
||||
);
|
||||
const { hasParallelContent } = useContentMetadata(msg);
|
||||
|
||||
if (!msg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getChatWidthClass = () => {
|
||||
if (maximizeChatSpace) {
|
||||
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
|
||||
}
|
||||
if (hasParallelContent) {
|
||||
return 'md:max-w-[58rem] xl:max-w-[70rem]';
|
||||
}
|
||||
return 'md:max-w-[47rem] xl:max-w-[55rem]';
|
||||
};
|
||||
|
||||
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]',
|
||||
chat: getChatWidthClass(),
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
|
|
@ -134,38 +120,29 @@ const ContentRender = memo(
|
|||
aria-label={getMessageAriaLabel(msg, localize)}
|
||||
className={cn(
|
||||
baseClasses.common,
|
||||
isCard ? baseClasses.card : baseClasses.chat,
|
||||
conditionalClasses.latestCard,
|
||||
conditionalClasses.cardRender,
|
||||
baseClasses.chat,
|
||||
conditionalClasses.focus,
|
||||
'message-render',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
|
||||
clickHandler();
|
||||
}
|
||||
}}
|
||||
role={showCardRender ? 'button' : undefined}
|
||||
tabIndex={showCardRender ? 0 : undefined}
|
||||
>
|
||||
{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-center">
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||
{!hasParallelContent && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-11/12 flex-col',
|
||||
'relative flex flex-col',
|
||||
hasParallelContent ? 'w-full' : 'w-11/12',
|
||||
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||
)}
|
||||
>
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
{!hasParallelContent && (
|
||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
|
|
@ -176,18 +153,17 @@ const ContentRender = memo(
|
|||
siblingIdx={siblingIdx}
|
||||
messageId={msg.messageId}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
searchResults={searchResults}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
isLatestMessage={isLatestMessage}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
isCreatedByUser={msg.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={msg.content as Array<TMessageContentParts | undefined>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(isSubmittingFamily || isSubmitting) && !(msg.children?.length ?? 0) ? (
|
||||
<PlaceholderRow isCard={isCard} />
|
||||
{hasNoChildren && effectiveIsSubmitting ? (
|
||||
<PlaceholderRow />
|
||||
) : (
|
||||
<SubRow classes="text-xs">
|
||||
<SiblingSwitch
|
||||
|
|
@ -197,8 +173,8 @@ const ContentRender = memo(
|
|||
/>
|
||||
<HoverButtons
|
||||
index={index}
|
||||
isEditing={edit}
|
||||
message={msg}
|
||||
isEditing={edit}
|
||||
enterEdit={enterEdit}
|
||||
isSubmitting={isSubmitting}
|
||||
conversation={conversation ?? null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { useMessageProcess } from '~/hooks';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
|
||||
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
|
||||
import ContentRender from './ContentRender';
|
||||
|
||||
|
|
@ -26,14 +26,9 @@ const MessageContainer = React.memo(
|
|||
);
|
||||
|
||||
export default function MessageContent(props: TMessageProps) {
|
||||
const {
|
||||
showSibling,
|
||||
conversation,
|
||||
handleScroll,
|
||||
siblingMessage,
|
||||
latestMultiMessage,
|
||||
isSubmittingFamily,
|
||||
} = useMessageProcess({ message: props.message });
|
||||
const { conversation, handleScroll, isSubmitting } = useMessageProcess({
|
||||
message: props.message,
|
||||
});
|
||||
const { message, currentEditId, setCurrentEditId } = props;
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
|
|
@ -45,29 +40,9 @@ export default function MessageContent(props: TMessageProps) {
|
|||
return (
|
||||
<>
|
||||
<MessageContainer handleScroll={handleScroll}>
|
||||
{showSibling ? (
|
||||
<div className="m-auto my-2 flex justify-center p-4 py-2 md:gap-6">
|
||||
<div className="flex w-full flex-row flex-wrap justify-between gap-1 md:max-w-5xl md:flex-nowrap md:gap-2 lg:max-w-5xl xl:max-w-6xl">
|
||||
<ContentRender
|
||||
{...props}
|
||||
message={message}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
isCard
|
||||
/>
|
||||
<ContentRender
|
||||
{...props}
|
||||
isMultiMessage
|
||||
isCard
|
||||
message={siblingMessage ?? latestMultiMessage ?? undefined}
|
||||
isSubmittingFamily={isSubmittingFamily}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<ContentRender {...props} />
|
||||
</div>
|
||||
)}
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<ContentRender {...props} isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
</MessageContainer>
|
||||
<MultiMessage
|
||||
key={messageId}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
type Props = {
|
||||
scrollHandler: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
export default function ScrollToBottom({ scrollHandler }: Props) {
|
||||
const ScrollToBottom = forwardRef<HTMLButtonElement, Props>(({ scrollHandler }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={scrollHandler}
|
||||
className="premium-scroll-button absolute bottom-5 right-1/2 cursor-pointer border border-border-light bg-surface-secondary"
|
||||
aria-label="Scroll to bottom"
|
||||
|
|
@ -22,4 +23,8 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
|
|||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ScrollToBottom.displayName = 'ScrollToBottom';
|
||||
|
||||
export default ScrollToBottom;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { useState, memo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useState, memo, useRef } from 'react';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import { FileText, LogOut } from 'lucide-react';
|
||||
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
|
||||
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
|
||||
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
|
||||
import FilesView from '~/components/Chat/Input/Files/FilesView';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Settings from './Settings';
|
||||
import store from '~/store';
|
||||
|
||||
function AccountSettings() {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -18,14 +16,16 @@ function AccountSettings() {
|
|||
enabled: !!isAuthenticated && startupConfig?.balance?.enabled,
|
||||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const accountSettingsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<Select.SelectProvider>
|
||||
<Select.Select
|
||||
ref={accountSettingsButtonRef}
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
data-testid="nav-user"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
|
||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-active-alt aria-[expanded=true]:bg-surface-active-alt"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
|
|
@ -40,7 +40,7 @@ function AccountSettings() {
|
|||
</div>
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[244px]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
translate: '0 -4px',
|
||||
|
|
@ -96,7 +96,13 @@ function AccountSettings() {
|
|||
{localize('com_nav_log_out')}
|
||||
</Select.SelectItem>
|
||||
</Select.SelectPopover>
|
||||
{showFiles && <FilesView open={showFiles} onOpenChange={setShowFiles} />}
|
||||
{showFiles && (
|
||||
<MyFilesModal
|
||||
open={showFiles}
|
||||
onOpenChange={setShowFiles}
|
||||
triggerRef={accountSettingsButtonRef}
|
||||
/>
|
||||
)}
|
||||
{showSettings && <Settings open={showSettings} onOpenChange={setShowSettings} />}
|
||||
</Select.SelectProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
|
||||
import { useState, useId, useMemo, useCallback } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
import { BookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import type * as t from '~/common';
|
||||
import type { FC } from 'react';
|
||||
import { useGetConversationTags } from '~/data-provider';
|
||||
import BookmarkNavItems from './BookmarkNavItems';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -16,56 +16,105 @@ type BookmarkNavProps = {
|
|||
|
||||
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps) => {
|
||||
const localize = useLocalize();
|
||||
const menuId = useId();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { data } = useGetConversationTags();
|
||||
|
||||
const label = useMemo(
|
||||
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
|
||||
[tags, localize],
|
||||
);
|
||||
|
||||
const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]);
|
||||
|
||||
const handleTagClick = useCallback(
|
||||
(tag: string) => {
|
||||
if (tags.includes(tag)) {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
} else {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
},
|
||||
[tags, setTags],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setTags([]);
|
||||
}, [setTags]);
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = useMemo(() => {
|
||||
const items: t.MenuItemProps[] = [
|
||||
{
|
||||
id: 'clear-all',
|
||||
label: localize('com_ui_clear_all'),
|
||||
icon: <CrossCircledIcon className="size-4" />,
|
||||
hideOnClick: false,
|
||||
onClick: handleClear,
|
||||
},
|
||||
];
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
items.push({
|
||||
id: 'no-bookmarks',
|
||||
label: localize('com_ui_no_bookmarks'),
|
||||
icon: '🤔',
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
for (const bookmark of bookmarks) {
|
||||
const isSelected = tags.includes(bookmark.tag);
|
||||
items.push({
|
||||
id: bookmark.tag,
|
||||
label: bookmark.tag,
|
||||
hideOnClick: false,
|
||||
icon: isSelected ? (
|
||||
<BookmarkFilledIcon className="size-4" />
|
||||
) : (
|
||||
<BookmarkIcon className="size-4" />
|
||||
),
|
||||
onClick: () => handleTagClick(bookmark.tag),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [bookmarks, tags, localize, handleTagClick, handleClear]);
|
||||
|
||||
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(
|
||||
'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"
|
||||
>
|
||||
{tags.length > 0 ? (
|
||||
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
) : (
|
||||
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
)}
|
||||
</MenuButton>
|
||||
}
|
||||
/>
|
||||
<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 && (
|
||||
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
|
||||
<BookmarkNavItems
|
||||
// List of selected tags(string)
|
||||
tags={tags}
|
||||
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
|
||||
setTags={setTags}
|
||||
/>
|
||||
</BookmarkContext.Provider>
|
||||
)}
|
||||
</MenuItems>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
menuId={menuId}
|
||||
focusLoop={true}
|
||||
isOpen={isMenuOpen}
|
||||
unmountOnHide={true}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
keyPrefix="bookmark-nav-"
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={label}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="bookmark-nav-menu-button"
|
||||
aria-label={localize('com_ui_bookmarks')}
|
||||
className={cn(
|
||||
'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-active-alt md:rounded-xl',
|
||||
isMenuOpen ? 'bg-surface-hover' : '',
|
||||
)}
|
||||
data-testid="bookmark-menu"
|
||||
>
|
||||
{tags.length > 0 ? (
|
||||
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
) : (
|
||||
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
|
||||
)}
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { type FC } from 'react';
|
||||
import { CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const BookmarkNavItems: FC<{
|
||||
tags: string[];
|
||||
setTags: (tags: string[]) => void;
|
||||
}> = ({ tags = [], setTags }) => {
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const getUpdatedSelected = (tag: string) => {
|
||||
if (tags.some((selectedTag) => selectedTag === tag)) {
|
||||
return tags.filter((selectedTag) => selectedTag !== tag);
|
||||
} else {
|
||||
return [...tags, tag];
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (tag?: string) => {
|
||||
if (tag === undefined) {
|
||||
return;
|
||||
}
|
||||
const updatedSelected = getUpdatedSelected(tag);
|
||||
setTags(updatedSelected);
|
||||
return;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setTags([]);
|
||||
return;
|
||||
};
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<BookmarkItem
|
||||
tag={localize('com_ui_clear_all')}
|
||||
data-testid="bookmark-item-clear"
|
||||
handleSubmit={clear}
|
||||
selected={false}
|
||||
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
|
||||
/>
|
||||
<BookmarkItem
|
||||
tag={localize('com_ui_no_bookmarks')}
|
||||
data-testid="bookmark-item-no-bookmarks"
|
||||
handleSubmit={() => Promise.resolve()}
|
||||
selected={false}
|
||||
icon={'🤔'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<BookmarkItems
|
||||
tags={tags}
|
||||
handleSubmit={handleSubmit}
|
||||
header={
|
||||
<BookmarkItem
|
||||
tag={localize('com_ui_clear_all')}
|
||||
data-testid="bookmark-item-clear"
|
||||
handleSubmit={clear}
|
||||
selected={false}
|
||||
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkNavItems;
|
||||
|
|
@ -4,14 +4,13 @@ import { LayoutGrid } from 'lucide-react';
|
|||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useFavorites, useLocalize, useShowMarketplace, useNewConvo } from '~/hooks';
|
||||
import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { useAssistantsMapContext } from '~/Providers';
|
||||
import FavoriteItem from './FavoriteItem';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -121,13 +120,13 @@ export default function FavoritesList({
|
|||
}) {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const search = useRecoilValue(store.search);
|
||||
const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites();
|
||||
const showAgentMarketplace = useShowMarketplace();
|
||||
|
||||
const { newConversation } = useNewConvo();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
|
|
@ -168,59 +167,56 @@ export default function FavoritesList({
|
|||
newChatButton?.focus();
|
||||
}, []);
|
||||
|
||||
// Ensure favorites is always an array (could be corrupted in localStorage)
|
||||
const safeFavorites = useMemo(() => (Array.isArray(favorites) ? favorites : []), [favorites]);
|
||||
|
||||
const agentIds = safeFavorites.map((f) => f.agentId).filter(Boolean) as string[];
|
||||
const allAgentIds = useMemo(
|
||||
() => safeFavorites.map((f) => f.agentId).filter(Boolean) as string[],
|
||||
[safeFavorites],
|
||||
);
|
||||
|
||||
const agentQueries = useQueries({
|
||||
queries: agentIds.map((agentId) => ({
|
||||
const missingAgentIds = useMemo(() => {
|
||||
if (agentsMap === undefined) {
|
||||
return [];
|
||||
}
|
||||
return allAgentIds.filter((id) => !agentsMap[id]);
|
||||
}, [allAgentIds, agentsMap]);
|
||||
|
||||
const missingAgentQueries = useQueries({
|
||||
queries: missingAgentIds.map((agentId) => ({
|
||||
queryKey: [QueryKeys.agent, agentId],
|
||||
queryFn: () => dataService.getAgentById({ agent_id: agentId }),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
enabled: missingAgentIds.length > 0,
|
||||
})),
|
||||
});
|
||||
|
||||
const isAgentsLoading = agentIds.length > 0 && agentQueries.some((q) => q.isLoading);
|
||||
const combinedAgentsMap = useMemo(() => {
|
||||
if (agentsMap === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const combined: Record<string, t.Agent> = {};
|
||||
for (const [key, value] of Object.entries(agentsMap)) {
|
||||
if (value) {
|
||||
combined[key] = value;
|
||||
}
|
||||
}
|
||||
missingAgentQueries.forEach((query) => {
|
||||
if (query.data) {
|
||||
combined[query.data.id] = query.data;
|
||||
}
|
||||
});
|
||||
return combined;
|
||||
}, [agentsMap, missingAgentQueries]);
|
||||
|
||||
const isAgentsLoading =
|
||||
(allAgentIds.length > 0 && agentsMap === undefined) ||
|
||||
(missingAgentIds.length > 0 && missingAgentQueries.some((q) => q.isLoading));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAgentsLoading && onHeightChange) {
|
||||
onHeightChange();
|
||||
}
|
||||
}, [isAgentsLoading, onHeightChange]);
|
||||
const agentsMap = useMemo(() => {
|
||||
const map: Record<string, t.Agent> = {};
|
||||
|
||||
const addToMap = (agent: t.Agent) => {
|
||||
if (agent && agent.id && !map[agent.id]) {
|
||||
map[agent.id] = agent;
|
||||
}
|
||||
};
|
||||
|
||||
const marketplaceData = queryClient.getQueriesData<InfiniteData<t.AgentListResponse>>([
|
||||
QueryKeys.marketplaceAgents,
|
||||
]);
|
||||
marketplaceData.forEach(([_, data]) => {
|
||||
data?.pages.forEach((page) => {
|
||||
page.data.forEach(addToMap);
|
||||
});
|
||||
});
|
||||
|
||||
const agentsListData = queryClient.getQueriesData<t.AgentListResponse>([QueryKeys.agents]);
|
||||
agentsListData.forEach(([_, data]) => {
|
||||
if (data && Array.isArray(data.data)) {
|
||||
data.data.forEach(addToMap);
|
||||
}
|
||||
});
|
||||
|
||||
agentQueries.forEach((query) => {
|
||||
if (query.data) {
|
||||
map[query.data.id] = query.data;
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [agentQueries, queryClient]);
|
||||
|
||||
const draggedFavoritesRef = useRef(safeFavorites);
|
||||
|
||||
|
|
@ -306,7 +302,7 @@ export default function FavoritesList({
|
|||
)}
|
||||
{safeFavorites.map((fav, index) => {
|
||||
if (fav.agentId) {
|
||||
const agent = agentsMap[fav.agentId];
|
||||
const agent = combinedAgentsMap?.[fav.agentId];
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function MobileNav({
|
|||
const { title = 'New Chat' } = conversation || {};
|
||||
|
||||
return (
|
||||
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-white pl-1 dark:bg-gray-800 dark:text-white md:hidden">
|
||||
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-presentation pl-1 dark:text-white md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mobile-header-new-chat-button"
|
||||
|
|
@ -29,7 +29,7 @@ export default function MobileNav({
|
|||
navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')
|
||||
}
|
||||
aria-live="polite"
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
|
||||
onClick={() =>
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
|
|
@ -62,7 +62,7 @@ export default function MobileNav({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
|
||||
onClick={() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
memo,
|
||||
lazy,
|
||||
Suspense,
|
||||
useRef,
|
||||
startTransition,
|
||||
} from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import type { List } from 'react-virtualized';
|
||||
import {
|
||||
useLocalize,
|
||||
useHasAccess,
|
||||
|
|
@ -12,7 +23,7 @@ import {
|
|||
useLocalStorage,
|
||||
useNavScrolling,
|
||||
} from '~/hooks';
|
||||
import { useConversationsInfiniteQuery } from '~/data-provider';
|
||||
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
|
||||
import { Conversations } from '~/components/Conversations';
|
||||
import SearchBar from './SearchBar';
|
||||
import NewChat from './NewChat';
|
||||
|
|
@ -22,8 +33,10 @@ import store from '~/store';
|
|||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||
|
||||
const NAV_WIDTH_DESKTOP = '260px';
|
||||
const NAV_WIDTH_MOBILE = '320px';
|
||||
export const NAV_WIDTH = {
|
||||
MOBILE: 320,
|
||||
DESKTOP: 260,
|
||||
} as const;
|
||||
|
||||
const SearchBarSkeleton = memo(() => (
|
||||
<div className={cn('flex h-10 items-center py-2')}>
|
||||
|
|
@ -63,8 +76,8 @@ const Nav = memo(
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
useTitleGeneration(isAuthenticated);
|
||||
|
||||
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
|
||||
|
|
@ -120,13 +133,17 @@ const Nav = memo(
|
|||
}, [data]);
|
||||
|
||||
const toggleNavVisible = useCallback(() => {
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
// Use startTransition to mark this as a non-urgent update
|
||||
// This prevents blocking the main thread during the cascade of re-renders
|
||||
startTransition(() => {
|
||||
setNavVisible((prev: boolean) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
return !prev;
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
});
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
}, [newUser, setNavVisible, setNewUser]);
|
||||
|
||||
const itemToggleNav = useCallback(() => {
|
||||
|
|
@ -141,9 +158,6 @@ const Nav = memo(
|
|||
if (savedNavVisible === null) {
|
||||
toggleNavVisible();
|
||||
}
|
||||
setNavWidth(NAV_WIDTH_MOBILE);
|
||||
} else {
|
||||
setNavWidth(NAV_WIDTH_DESKTOP);
|
||||
}
|
||||
}, [isSmallScreen, toggleNavVisible]);
|
||||
|
||||
|
|
@ -199,61 +213,90 @@ const Nav = memo(
|
|||
}
|
||||
}, [search.query, search.isTyping, isLoading, isFetching]);
|
||||
|
||||
// Always render sidebar to avoid mount/unmount costs
|
||||
// Use transform for GPU-accelerated animation (no layout thrashing)
|
||||
const sidebarWidth = isSmallScreen ? NAV_WIDTH.MOBILE : NAV_WIDTH.DESKTOP;
|
||||
|
||||
// Sidebar content (shared between mobile and desktop)
|
||||
const sidebarContent = (
|
||||
<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-2 pb-3.5"
|
||||
aria-hidden={!navVisible}
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
|
||||
<MemoNewChat
|
||||
subHeaders={subHeaders}
|
||||
toggleNav={toggleNavVisible}
|
||||
headerButtons={headerButtons}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
moveToTop={moveToTop}
|
||||
toggleNav={itemToggleNav}
|
||||
containerRef={conversationsRef}
|
||||
loadMoreConversations={loadMoreConversations}
|
||||
isLoading={isFetchingNextPage || showLoading || isLoading}
|
||||
isSearchLoading={isSearchLoading}
|
||||
isChatsExpanded={isChatsExpanded}
|
||||
setIsChatsExpanded={setIsChatsExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile: Fixed positioned sidebar that slides over content
|
||||
// Uses CSS transitions (not Framer Motion) to sync perfectly with content animation
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="nav"
|
||||
className={cn(
|
||||
'nav fixed left-0 top-0 z-[70] h-full bg-surface-primary-alt',
|
||||
navVisible && 'active',
|
||||
)}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
transform: navVisible ? 'translateX(0)' : `translateX(-${sidebarWidth}px)`,
|
||||
transition: 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
<NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: Inline sidebar with width transition
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{navVisible && (
|
||||
<motion.div
|
||||
data-testid="nav"
|
||||
className={cn(
|
||||
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
|
||||
'md:max-w-[260px]',
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: navWidth }}
|
||||
exit={{ width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
key="nav"
|
||||
>
|
||||
<div className="h-full w-[320px] md:w-[260px]">
|
||||
<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-2 pb-3.5"
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
|
||||
<MemoNewChat
|
||||
subHeaders={subHeaders}
|
||||
toggleNav={toggleNavVisible}
|
||||
headerButtons={headerButtons}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
|
||||
<Conversations
|
||||
conversations={conversations}
|
||||
moveToTop={moveToTop}
|
||||
toggleNav={itemToggleNav}
|
||||
containerRef={conversationsRef}
|
||||
loadMoreConversations={loadMoreConversations}
|
||||
isLoading={isFetchingNextPage || showLoading || isLoading}
|
||||
isSearchLoading={isSearchLoading}
|
||||
isChatsExpanded={isChatsExpanded}
|
||||
setIsChatsExpanded={setIsChatsExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
|
||||
</>
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden"
|
||||
style={{ width: navVisible ? sidebarWidth : 0, transition: 'width 0.2s ease-out' }}
|
||||
>
|
||||
<motion.div
|
||||
data-testid="nav"
|
||||
className={cn('nav h-full bg-surface-primary-alt', navVisible && 'active')}
|
||||
style={{ width: sidebarWidth }}
|
||||
initial={false}
|
||||
animate={{
|
||||
x: navVisible ? 0 : -sidebarWidth,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,13 +66,13 @@ export default function NewChat({
|
|||
data-testid="close-sidebar-button"
|
||||
aria-label={localize('com_nav_close_sidebar')}
|
||||
aria-expanded={true}
|
||||
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
|
||||
onClick={handleToggleNav}
|
||||
>
|
||||
<Sidebar aria-hidden="true" className="max-md:hidden" />
|
||||
<MobileSidebar
|
||||
aria-hidden="true"
|
||||
className="m-1 inline-flex size-10 items-center justify-center md:hidden"
|
||||
className="icon-lg m-1 inline-flex items-center justify-center md:hidden"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export default function NewChat({
|
|||
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"
|
||||
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
|
||||
onClick={clickHandler}
|
||||
>
|
||||
<NewChatIcon className="icon-lg text-text-primary" />
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover"
|
||||
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-active-alt hover:bg-surface-active-alt"
|
||||
>
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -84,6 +84,13 @@ const toggleSwitchConfigs = [
|
|||
hoverCardText: 'com_nav_info_default_temporary_chat',
|
||||
key: 'defaultTemporaryChat',
|
||||
},
|
||||
{
|
||||
stateAtom: store.resumableStreams,
|
||||
localizationKey: 'com_nav_resumable_streams',
|
||||
switchId: 'resumableStreams',
|
||||
hoverCardText: 'com_nav_info_resumable_streams',
|
||||
key: 'resumableStreams',
|
||||
},
|
||||
];
|
||||
|
||||
function Chat() {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import {
|
||||
OGDialog,
|
||||
useToastContext,
|
||||
|
|
@ -62,14 +63,6 @@ export default function SharedLinks() {
|
|||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
|
|
@ -120,7 +113,7 @@ export default function SharedLinks() {
|
|||
|
||||
if (validRows.length === 0) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_valid_items'),
|
||||
message: localize('com_ui_no_valid_items' as TranslationKeys),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
|
|
@ -134,15 +127,15 @@ export default function SharedLinks() {
|
|||
showToast({
|
||||
message: localize(
|
||||
validRows.length === 1
|
||||
? 'com_ui_shared_link_delete_success'
|
||||
: 'com_ui_shared_link_bulk_delete_success',
|
||||
? ('com_ui_shared_link_delete_success' as TranslationKeys)
|
||||
: ('com_ui_shared_link_bulk_delete_success' as TranslationKeys),
|
||||
),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete shared links:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_bulk_delete_error'),
|
||||
message: localize('com_ui_bulk_delete_error' as TranslationKeys),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
|
|
@ -168,26 +161,28 @@ export default function SharedLinks() {
|
|||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'title';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_name_sort')}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
|
@ -218,26 +213,28 @@ export default function SharedLinks() {
|
|||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
aria-label={localize('com_ui_creation_date_sort')}
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_ui_creation_date_sort' as TranslationKeys)}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
|
@ -300,7 +297,7 @@ export default function SharedLinks() {
|
|||
),
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize, queryParams, handleSort],
|
||||
[isSmallScreen, localize],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import {
|
||||
useConversationsInfiniteQuery,
|
||||
useDeleteConversationMutation,
|
||||
|
|
@ -56,14 +57,6 @@ export default function ArchivedChatsTable({
|
|||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
|
|
@ -133,25 +126,28 @@ export default function ArchivedChatsTable({
|
|||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'title';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_nav_archive_name_sort' as TranslationKeys)}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_nav_archive_name')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
|
@ -180,26 +176,28 @@ export default function ArchivedChatsTable({
|
|||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
header: ({ column }) => {
|
||||
const sortState = column.getIsSorted();
|
||||
let SortIcon = ArrowUpDown;
|
||||
let ariaSort: 'ascending' | 'descending' | 'none' = 'none';
|
||||
if (sortState === 'desc') {
|
||||
SortIcon = ArrowDown;
|
||||
ariaSort = 'descending';
|
||||
} else if (sortState === 'asc') {
|
||||
SortIcon = ArrowUp;
|
||||
ariaSort = 'ascending';
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
aria-label={localize('com_nav_archive_created_at_sort')}
|
||||
aria-sort={ariaSort}
|
||||
aria-label={localize('com_nav_archive_created_at_sort' as TranslationKeys)}
|
||||
aria-current={sortState ? 'true' : 'false'}
|
||||
>
|
||||
{localize('com_nav_archive_created_at')}
|
||||
{isSorted && sortDirection === 'asc' && (
|
||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{isSorted && sortDirection === 'desc' && (
|
||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
||||
<SortIcon className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
|
@ -270,7 +268,7 @@ export default function ArchivedChatsTable({
|
|||
},
|
||||
},
|
||||
],
|
||||
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
|
||||
[isSmallScreen, localize, unarchiveMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * from './ExportConversation';
|
||||
export * from './SettingsTabs/';
|
||||
export { default as MobileNav } from './MobileNav';
|
||||
export { default as Nav } from './Nav';
|
||||
export { default as Nav, NAV_WIDTH } from './Nav';
|
||||
export { default as NavLink } from './NavLink';
|
||||
export { default as NewChat } from './NewChat';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
|
|
|
|||
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