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

This commit is contained in:
Ruben Talstra 2025-07-04 11:01:33 +02:00 committed by GitHub
commit 97a6074edc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
660 changed files with 35171 additions and 17122 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.7.8",
"version": "v0.7.9-rc1",
"description": "",
"type": "module",
"scripts": {
@ -65,6 +65,7 @@
"export-from-json": "^1.7.2",
"filenamify": "^6.0.0",
"framer-motion": "^11.5.4",
"heic-to": "^1.1.14",
"html-to-image": "^1.11.11",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.3",
@ -74,6 +75,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.394.0",
"match-sorter": "^6.3.4",
"micromark-extension-llm-math": "^3.1.0",
"qrcode.react": "^4.2.0",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
@ -139,7 +141,6 @@
"postcss": "^8.4.31",
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.2.0",
"rollup-plugin-visualizer": "^6.0.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",

View file

@ -0,0 +1,37 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface ActivePanelContextType {
active: string | undefined;
setActive: (id: string) => void;
}
const ActivePanelContext = createContext<ActivePanelContextType | undefined>(undefined);
export function ActivePanelProvider({
children,
defaultActive,
}: {
children: ReactNode;
defaultActive?: string;
}) {
const [active, _setActive] = useState<string | undefined>(defaultActive);
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id);
_setActive(id);
};
return (
<ActivePanelContext.Provider value={{ active, setActive }}>
{children}
</ActivePanelContext.Provider>
);
}
export function useActivePanel() {
const context = useContext(ActivePanelContext);
if (context === undefined) {
throw new Error('useActivePanel must be used within an ActivePanelProvider');
}
return context;
}

View file

@ -0,0 +1,96 @@
import React, { createContext, useContext, useState } from 'react';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider';
import type { AgentPanelContextType } from '~/common';
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
export function useAgentPanelContext() {
const context = useContext(AgentPanelContext);
if (context === undefined) {
throw new Error('useAgentPanelContext must be used within an AgentPanelProvider');
}
return context;
}
/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */
export function AgentPanelProvider({ children }: { children: React.ReactNode }) {
const localize = useLocalize();
const [mcp, setMcp] = useState<MCP | undefined>(undefined);
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
const [action, setAction] = useState<Action | undefined>(undefined);
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined);
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
enabled: !!agent_id,
});
const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
enabled: !!agent_id,
});
const tools =
pluginTools?.map((tool) => ({
tool_id: tool.pluginKey,
metadata: tool as TPlugin,
agent_id: agent_id || '',
})) || [];
const groupedTools = tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
agent_id: agent_id || '',
tools: [],
};
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
);
const value = {
action,
setAction,
mcp,
setMcp,
mcps,
setMcps,
activePanel,
setActivePanel,
setCurrentAgentId,
agent_id,
groupedTools,
/** Query data for actions and tools */
actions,
tools,
};
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
}

View file

@ -1,8 +1,8 @@
import { useForm, FormProvider } from 'react-hook-form';
import { createContext, useContext } from 'react';
import { defaultAgentFormValues } from 'librechat-data-provider';
import type { UseFormReturn } from 'react-hook-form';
import type { AgentForm } from '~/common';
import { getDefaultAgentFormValues } from '~/utils';
type AgentsContextType = UseFormReturn<AgentForm>;
@ -20,7 +20,7 @@ export function useAgentsContext() {
export default function AgentsProvider({ children }) {
const methods = useForm<AgentForm>({
defaultValues: defaultAgentFormValues,
defaultValues: getDefaultAgentFormValues(),
});
return <FormProvider {...methods}>{children}</FormProvider>;

View file

@ -0,0 +1,89 @@
import React, { createContext, useContext } from 'react';
import { Tools, LocalStorageKeys } from 'librechat-data-provider';
import { useMCPSelect, useToolToggle, useCodeApiKeyForm, useSearchApiKeyForm } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
interface BadgeRowContextType {
conversationId?: string | null;
mcpSelect: ReturnType<typeof useMCPSelect>;
webSearch: ReturnType<typeof useToolToggle>;
codeInterpreter: ReturnType<typeof useToolToggle>;
fileSearch: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
startupConfig: ReturnType<typeof useGetStartupConfig>['data'];
}
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
export function useBadgeRowContext() {
const context = useContext(BadgeRowContext);
if (context === undefined) {
throw new Error('useBadgeRowContext must be used within a BadgeRowProvider');
}
return context;
}
interface BadgeRowProviderProps {
children: React.ReactNode;
conversationId?: string | null;
}
export default function BadgeRowProvider({ children, conversationId }: BadgeRowProviderProps) {
/** Startup config */
const { data: startupConfig } = useGetStartupConfig();
/** MCPSelect hook */
const mcpSelect = useMCPSelect({ conversationId });
/** CodeInterpreter hooks */
const codeApiKeyForm = useCodeApiKeyForm({});
const { setIsDialogOpen: setCodeDialogOpen } = codeApiKeyForm;
const codeInterpreter = useToolToggle({
conversationId,
setIsDialogOpen: setCodeDialogOpen,
toolKey: Tools.execute_code,
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
authConfig: {
toolId: Tools.execute_code,
queryOptions: { retry: 1 },
},
});
/** WebSearch hooks */
const searchApiKeyForm = useSearchApiKeyForm({});
const { setIsDialogOpen: setWebSearchDialogOpen } = searchApiKeyForm;
const webSearch = useToolToggle({
conversationId,
toolKey: Tools.web_search,
localStorageKey: LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
setIsDialogOpen: setWebSearchDialogOpen,
authConfig: {
toolId: Tools.web_search,
queryOptions: { retry: 1 },
},
});
/** FileSearch hook */
const fileSearch = useToolToggle({
conversationId,
toolKey: Tools.file_search,
localStorageKey: LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_,
isAuthenticated: true,
});
const value: BadgeRowContextType = {
mcpSelect,
webSearch,
fileSearch,
startupConfig,
conversationId,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
};
return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;
}

View file

@ -1,6 +1,8 @@
export { default as ToastProvider } from './ToastContext';
export { default as AssistantsProvider } from './AssistantsContext';
export { default as AgentsProvider } from './AgentsContext';
export { default as ToastProvider } from './ToastContext';
export * from './ActivePanelContext';
export * from './AgentPanelContext';
export * from './ChatContext';
export * from './ShareContext';
export * from './ToastContext';
@ -21,3 +23,5 @@ export * from './CodeBlockContext';
export * from './ToolCallsMapContext';
export * from './SetConvoContext';
export * from './SearchContext';
export * from './BadgeRowContext';
export { default as BadgeRowProvider } from './BadgeRowContext';

26
client/src/common/mcp.ts Normal file
View file

@ -0,0 +1,26 @@
import {
AuthorizationTypeEnum,
AuthTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import { MCPForm } from '~/common/types';
export const defaultMCPFormValues: MCPForm = {
type: AuthTypeEnum.None,
saved_auth_fields: false,
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
name: '',
description: '',
url: '',
tools: [],
icon: '',
trust: false,
};

View file

@ -143,6 +143,7 @@ export enum Panel {
actions = 'actions',
model = 'model',
version = 'version',
mcp = 'mcp',
}
export type FileSetter =
@ -166,6 +167,15 @@ export type ActionAuthForm = {
token_exchange_method: t.TokenExchangeMethodEnum;
};
export type MCPForm = ActionAuthForm & {
name?: string;
description?: string;
url?: string;
tools?: string[];
icon?: string;
trust?: boolean;
};
export type ActionWithNullableMetadata = Omit<t.Action, 'metadata'> & {
metadata: t.ActionMetadata | null;
};
@ -188,16 +198,35 @@ export type AgentPanelProps = {
index?: number;
agent_id?: string;
activePanel?: string;
mcp?: t.MCP;
mcps?: t.MCP[];
action?: t.Action;
actions?: t.Action[];
createMutation: UseMutationResult<t.Agent, Error, t.AgentCreateParams>;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
endpointsConfig?: t.TEndpointsConfig;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agentsConfig?: t.TAgentsEndpoint | null;
};
export type AgentPanelContextType = {
action?: t.Action;
actions?: t.Action[];
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
mcp?: t.MCP;
mcps?: t.MCP[];
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
tools: t.AgentToolType[];
activePanel?: string;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
groupedTools?: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
agent_id?: string;
};
export type AgentModelPanelProps = {
agent_id?: string;
providers: Option[];
@ -307,6 +336,11 @@ export type TAskProps = {
export type TOptions = {
editedMessageId?: string | null;
editedText?: string | null;
editedContent?: {
index: number;
text: string;
type: 'text' | 'think';
};
isRegenerate?: boolean;
isContinued?: boolean;
isEdited?: boolean;
@ -457,11 +491,20 @@ export type VoiceOption = {
};
export type TMessageAudio = {
messageId?: string;
content?: t.TMessageContentParts[] | string;
className?: string;
isLast: boolean;
isLast?: boolean;
index: number;
messageId: string;
content: string;
className?: string;
renderButton?: (props: {
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
title: string;
icon: React.ReactNode;
isActive?: boolean;
isVisible?: boolean;
isDisabled?: boolean;
className?: string;
}) => React.ReactNode;
};
export type OptionWithIcon = Option & { icon?: React.ReactNode };

View file

@ -40,7 +40,7 @@ const defaultType = 'unknown';
const defaultIdentifier = 'lc-no-identifier';
export function Artifact({
node,
node: _node,
...props
}: Artifact & {
children: React.ReactNode | { props: { children: React.ReactNode } };
@ -95,7 +95,7 @@ export function Artifact({
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
prevArtifacts[artifactKey].content === content
prevArtifacts[artifactKey]?.content === content
) {
return prevArtifacts;
}

View file

@ -1,37 +0,0 @@
// client/src/hooks/useDebounceCodeBlock.ts
import { useCallback, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useSetRecoilState } from 'recoil';
import { codeBlocksState, codeBlockIdsState } from '~/store/artifacts';
import type { CodeBlock } from '~/common';
export function useDebounceCodeBlock() {
const setCodeBlocks = useSetRecoilState(codeBlocksState);
const setCodeBlockIds = useSetRecoilState(codeBlockIdsState);
const updateCodeBlock = useCallback((codeBlock: CodeBlock) => {
console.log('Updating code block:', codeBlock);
setCodeBlocks((prev) => ({
...prev,
[codeBlock.id]: codeBlock,
}));
setCodeBlockIds((prev) =>
prev.includes(codeBlock.id) ? prev : [...prev, codeBlock.id],
);
}, [setCodeBlocks, setCodeBlockIds]);
const debouncedUpdateCodeBlock = useCallback(
debounce((codeBlock: CodeBlock) => {
updateCodeBlock(codeBlock);
}, 25),
[updateCodeBlock],
);
useEffect(() => {
return () => {
debouncedUpdateCodeBlock.cancel();
};
}, [debouncedUpdateCodeBlock]);
return debouncedUpdateCodeBlock;
}

View file

@ -1,5 +1,5 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageAudio } from '~/common';
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
@ -7,7 +7,14 @@ import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components';
import { logger } from '~/utils';
import store from '~/store';
export function BrowserTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
export function BrowserTTS({
isLast,
index,
messageId,
content,
className,
renderButton,
}: TMessageAudio) {
const localize = useLocalize();
const playbackRate = useRecoilValue(store.playbackRate);
@ -18,16 +25,16 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
content,
});
const renderIcon = (size: string) => {
const renderIcon = () => {
if (isLoading === true) {
return <Spinner size={size} />;
return <Spinner className="icon-md-heavy h-[18px] w-[18px]" />;
}
if (isSpeaking === true) {
return <VolumeMuteIcon size={size} />;
return <VolumeMuteIcon className="icon-md-heavy h-[18px] w-[18px]" />;
}
return <VolumeIcon size={size} />;
return <VolumeIcon className="icon-md-heavy h-[18px] w-[18px]" />;
};
useEffect(() => {
@ -46,21 +53,30 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
audioRef.current,
);
const handleClick = () => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
};
const title = isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud');
return (
<>
<button
className={className}
onClickCapture={() => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
}}
type="button"
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
>
{renderIcon('19')}
</button>
{renderButton ? (
renderButton({
onClick: handleClick,
title: title,
icon: renderIcon(),
isActive: isSpeaking,
className,
})
) : (
<button className={className} onClickCapture={handleClick} type="button" title={title}>
{renderIcon()}
</button>
)}
<audio
ref={audioRef}
controls
@ -84,7 +100,14 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
);
}
export function ExternalTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
export function ExternalTTS({
isLast,
index,
messageId,
content,
className,
renderButton,
}: TMessageAudio) {
const localize = useLocalize();
const playbackRate = useRecoilValue(store.playbackRate);
@ -95,16 +118,16 @@ export function ExternalTTS({ isLast, index, messageId, content, className }: TM
content,
});
const renderIcon = (size: string) => {
const renderIcon = () => {
if (isLoading === true) {
return <Spinner size={size} />;
return <Spinner className="icon-md-heavy h-[18px] w-[18px]" />;
}
if (isSpeaking === true) {
return <VolumeMuteIcon size={size} />;
return <VolumeMuteIcon className="icon-md-heavy h-[18px] w-[18px]" />;
}
return <VolumeIcon size={size} />;
return <VolumeIcon className="icon-md-heavy h-[18px] w-[18px]" />;
};
useEffect(() => {
@ -125,19 +148,33 @@ export function ExternalTTS({ isLast, index, messageId, content, className }: TM
return (
<>
<button
className={className}
onClickCapture={() => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
}}
type="button"
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
>
{renderIcon('19')}
</button>
{renderButton ? (
renderButton({
onClick: () => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
},
title: isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud'),
icon: renderIcon(),
isActive: isSpeaking,
className,
})
) : (
<button
onClickCapture={() => {
if (audioRef.current) {
audioRef.current.muted = false;
}
toggleSpeech();
}}
type="button"
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
>
{renderIcon()}
</button>
)}
<audio
ref={audioRef}
controls

View file

@ -1,23 +1,12 @@
import { TranslationKeys, useLocalize } from '~/hooks';
import { BlinkAnimation } from './BlinkAnimation';
import { TStartupConfig } from 'librechat-data-provider';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialLoginRender from './SocialLoginRender';
import { ThemeSelector } from '~/components/ui';
import { BlinkAnimation } from './BlinkAnimation';
import { ThemeSelector } from '~/components';
import { Banner } from '../Banners';
import Footer from './Footer';
const ErrorRender = ({ children }: { children: React.ReactNode }) => (
<div className="mt-16 flex justify-center">
<div
role="alert"
aria-live="assertive"
className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200"
>
{children}
</div>
</div>
);
function AuthLayout({
children,
header,
@ -40,19 +29,29 @@ function AuthLayout({
const hasStartupConfigError = startupConfigError !== null && startupConfigError !== undefined;
const DisplayError = () => {
if (hasStartupConfigError) {
return <ErrorRender>{localize('com_auth_error_login_server')}</ErrorRender>;
return (
<div className="mx-auto sm:max-w-sm">
<ErrorMessage>{localize('com_auth_error_login_server')}</ErrorMessage>
</div>
);
} else if (error === 'com_auth_error_invalid_reset_token') {
return (
<ErrorRender>
{localize('com_auth_error_invalid_reset_token')}{' '}
<a className="font-semibold text-green-600 hover:underline" href="/forgot-password">
{localize('com_auth_click_here')}
</a>{' '}
{localize('com_auth_to_try_again')}
</ErrorRender>
<div className="mx-auto sm:max-w-sm">
<ErrorMessage>
{localize('com_auth_error_invalid_reset_token')}{' '}
<a className="font-semibold text-green-600 hover:underline" href="/forgot-password">
{localize('com_auth_click_here')}
</a>{' '}
{localize('com_auth_to_try_again')}
</ErrorMessage>
</div>
);
} else if (error != null && error) {
return <ErrorRender>{localize(error)}</ErrorRender>;
return (
<div className="mx-auto sm:max-w-sm">
<ErrorMessage>{localize(error)}</ErrorMessage>
</div>
);
}
return null;
};
@ -87,8 +86,8 @@ function AuthLayout({
{children}
{!pathname.includes('2fa') &&
(pathname.includes('login') || pathname.includes('register')) && (
<SocialLoginRender startupConfig={startupConfig} />
)}
<SocialLoginRender startupConfig={startupConfig} />
)}
</div>
</div>
<Footer startupConfig={startupConfig} />

View file

@ -2,7 +2,7 @@ export const ErrorMessage = ({ children }: { children: React.ReactNode }) => (
<div
role="alert"
aria-live="assertive"
className="relative mt-6 rounded-lg border border-red-500/20 bg-red-50/50 px-6 py-4 text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100"
className="relative mt-6 rounded-xl border border-red-500/20 bg-red-50/50 px-6 py-4 text-red-700 shadow-sm transition-all dark:bg-red-950/30 dark:text-red-100"
>
{children}
</div>

View file

@ -3,11 +3,11 @@ import { useEffect, useState } from 'react';
import { useAuthContext } from '~/hooks/AuthContext';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from '~/components/Auth/ErrorMessage';
import SocialButton from '~/components/Auth/SocialButton';
import { OpenIDIcon } from '~/components';
import { getLoginError } from '~/utils';
import { useLocalize } from '~/hooks';
import LoginForm from './LoginForm';
import SocialButton from '~/components/Auth/SocialButton';
import { OpenIDIcon } from '~/components';
function Login() {
const localize = useLocalize();

View file

@ -5,6 +5,7 @@ import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
import type { TAuthContext } from '~/common';
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
import { ThemeContext, useLocalize } from '~/hooks';
import { Spinner, Button } from '~/components';
type TLoginFormProps = {
onSubmit: (data: TLoginUser) => void;
@ -20,7 +21,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
register,
getValues,
handleSubmit,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<TLoginUser>();
const [showResendLink, setShowResendLink] = useState<boolean>(false);
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
@ -165,15 +166,16 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
)}
<div className="mt-6">
<button
<Button
aria-label={localize('com_auth_continue')}
data-testid="login-button"
type="submit"
disabled={requireCaptcha && !turnstileToken}
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
disabled={(requireCaptcha && !turnstileToken) || isSubmitting}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{localize('com_auth_continue')}
</button>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
</>

View file

@ -4,10 +4,10 @@ import { Turnstile } from '@marsidev/react-turnstile';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { ErrorMessage } from './ErrorMessage';
import { Spinner } from '~/components/svg';
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';
import type { TLoginLayoutContext } from '~/common';
import { Spinner, Button } from '~/components';
import { ErrorMessage } from './ErrorMessage';
const Registration: React.FC = () => {
const navigate = useNavigate();
@ -194,7 +194,7 @@ const Registration: React.FC = () => {
)}
<div className="mt-6">
<button
<Button
disabled={
Object.keys(errors).length > 0 ||
isSubmitting ||
@ -202,10 +202,11 @@ const Registration: React.FC = () => {
}
type="submit"
aria-label="Submit registration"
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
variant="submit"
className="h-12 w-full rounded-2xl"
>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</button>
</Button>
</div>
</form>

View file

@ -5,12 +5,13 @@ import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-q
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { FC } from 'react';
import type { TLoginLayoutContext } from '~/common';
import { Spinner, Button } from '~/components';
import { useLocalize } from '~/hooks';
const BodyTextWrapper: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div
className="relative mt-6 rounded-lg border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
className="relative mt-6 rounded-xl border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
{children}
@ -44,6 +45,7 @@ function RequestPasswordReset() {
const { startupConfig, setHeaderText } = useOutletContext<TLoginLayoutContext>();
const requestPasswordReset = useRequestPasswordResetMutation();
const { isLoading } = requestPasswordReset;
const onSubmit = (data: TRequestPasswordReset) => {
requestPasswordReset.mutate(data, {
@ -105,23 +107,12 @@ function RequestPasswordReset() {
},
})}
aria-invalid={!!errors.email}
className="
peer w-full rounded-lg border border-gray-300 bg-transparent px-4 py-3
text-base text-gray-900 placeholder-transparent transition-all
focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20
dark:border-gray-700 dark:text-white dark:focus:border-green-500
"
placeholder="email@example.com"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="email"
className="
absolute -top-2 left-2 z-10 bg-white px-2 text-sm text-gray-600
transition-all peer-placeholder-shown:top-3 peer-placeholder-shown:text-base
peer-placeholder-shown:text-gray-500 peer-focus:-top-2 peer-focus:text-sm
peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400
dark:peer-focus:text-green-500
"
className="absolute -top-2 left-2 z-10 bg-white px-2 text-sm text-gray-600 transition-all peer-placeholder-shown:top-3 peer-placeholder-shown:text-base peer-placeholder-shown:text-gray-500 peer-focus:-top-2 peer-focus:text-sm peer-focus:text-green-600 dark:bg-gray-900 dark:text-gray-400 dark:peer-focus:text-green-500"
>
{localize('com_auth_email_address')}
</label>
@ -133,18 +124,15 @@ function RequestPasswordReset() {
)}
</div>
<div className="space-y-4">
<button
<Button
aria-label="Continue with password reset"
type="submit"
disabled={!!errors.email}
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
disabled={!!errors.email || isLoading}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{localize('com_auth_continue')}
</button>
{isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button>
<a
href="/login"
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"

View file

@ -4,6 +4,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { useResetPasswordMutation } from 'librechat-data-provider/react-query';
import type { TResetPassword } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { Spinner, Button } from '~/components';
import { useLocalize } from '~/hooks';
function ResetPassword() {
@ -12,7 +13,7 @@ function ResetPassword() {
register,
handleSubmit,
watch,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<TResetPassword>();
const navigate = useNavigate();
const [params] = useSearchParams();
@ -35,18 +36,20 @@ function ResetPassword() {
return (
<>
<div
className="relative mb-8 mt-4 rounded-2xl border border-green-400 bg-green-100 px-4 py-3 text-center text-green-700 dark:bg-gray-900 dark:text-white"
className="relative mt-6 rounded-xl border border-green-500/20 bg-green-50/50 px-6 py-4 text-green-700 shadow-sm transition-all dark:bg-green-950/30 dark:text-green-100"
role="alert"
>
{localize('com_auth_login_with_new_password')}
<div className="flex flex-col space-y-4">
<p>{localize('com_auth_login_with_new_password')}</p>
<Button
onClick={() => navigate('/login')}
aria-label={localize('com_auth_sign_in')}
variant="submit"
>
{localize('com_auth_continue')}
</Button>
</div>
</div>
<button
onClick={() => navigate('/login')}
aria-label={localize('com_auth_sign_in')}
className="w-full transform rounded-2xl bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
{localize('com_auth_continue')}
</button>
</>
);
}
@ -89,20 +92,12 @@ function ResetPassword() {
},
})}
aria-invalid={!!errors.password}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="password"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password')}
</label>
@ -124,20 +119,12 @@ function ResetPassword() {
validate: (value) => value === password || localize('com_auth_password_not_match'),
})}
aria-invalid={!!errors.confirm_password}
className="
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
"
className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none"
placeholder=" "
/>
<label
htmlFor="confirm_password"
className="
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
"
className="absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
>
{localize('com_auth_password_confirm')}
</label>
@ -159,19 +146,15 @@ function ResetPassword() {
)}
</div>
<div className="mt-6">
<button
disabled={!!errors.password || !!errors.confirm_password}
<Button
type="submit"
aria-label={localize('com_auth_submit_registration')}
className="
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
"
disabled={!!errors.password || !!errors.confirm_password || isSubmitting}
variant="submit"
className="h-12 w-full rounded-2xl"
>
{localize('com_auth_continue')}
</button>
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
</Button>
</div>
</form>
);

View file

@ -1,11 +1,10 @@
import { useCallback, useState } from 'react';
import type { FC } from 'react';
import { Label, OGDialog, OGDialogTrigger, TooltipAnchor } from '~/components/ui';
import { Button, TrashIcon, Label, OGDialog, OGDialogTrigger, TooltipAnchor } from '~/components';
import { useDeleteConversationTagMutation } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
const DeleteBookmarkButton: FC<{
@ -36,31 +35,26 @@ const DeleteBookmarkButton: FC<{
await deleteBookmarkMutation.mutateAsync(bookmark);
}, [bookmark, deleteBookmarkMutation]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
setOpen(!open);
}
};
return (
<>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
role="button"
aria-label={localize('com_ui_bookmarks_delete')}
description={localize('com_ui_delete')}
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
onClick={() => setOpen(!open)}
onKeyDown={handleKeyDown}
>
<TrashIcon className="size-4" />
</TooltipAnchor>
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_delete')}
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<TrashIcon />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}

View file

@ -1,9 +1,8 @@
import { useState } from 'react';
import type { FC } from 'react';
import type { TConversationTag } from 'librechat-data-provider';
import { TooltipAnchor, OGDialogTrigger } from '~/components/ui';
import { TooltipAnchor, OGDialogTrigger, EditIcon, Button } from '~/components';
import BookmarkEditDialog from './BookmarkEditDialog';
import { EditIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
const EditBookmarkButton: FC<{
@ -15,12 +14,6 @@ const EditBookmarkButton: FC<{
const localize = useLocalize();
const [open, setOpen] = useState(false);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
setOpen(!open);
}
};
return (
<BookmarkEditDialog
context="EditBookmarkButton"
@ -30,18 +23,21 @@ const EditBookmarkButton: FC<{
>
<OGDialogTrigger asChild>
<TooltipAnchor
role="button"
aria-label={localize('com_ui_bookmarks_edit')}
description={localize('com_ui_edit')}
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
onClick={() => setOpen(!open)}
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onKeyDown={handleKeyDown}
>
<EditIcon />
</TooltipAnchor>
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</BookmarkEditDialog>
);

View file

@ -68,6 +68,7 @@ export default function ExportAndShareMenu({
return (
<>
<DropdownPopup
portal={true}
menuId={menuId}
focusLoop={true}
unmountOnHide={true}

View file

@ -37,20 +37,36 @@ export default function Header() {
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && <HeaderNewChat />}
{<ModelSelector startupConfig={startupConfig} />}
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}
{hasAccessToMultiConvo === true && <AddMultiConvo />}
{isSmallScreen && (
<>
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<TemporaryChat />
</>
)}
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${
!navVisible
? 'translate-x-0 opacity-100'
: 'pointer-events-none translate-x-[-100px] opacity-0'
}`}
>
<OpenSidebar setNavVisible={setNavVisible} />
<HeaderNewChat />
</div>
<div
className={`flex items-center gap-2 ${
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
>
<ModelSelector startupConfig={startupConfig} />
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />}
{hasAccessToMultiConvo === true && <AddMultiConvo />}
{isSmallScreen && (
<>
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<TemporaryChat />
</>
)}
</div>
</div>
{!isSmallScreen && (
<div className="flex items-center gap-2">

View file

@ -1,19 +1,23 @@
import React, {
memo,
useState,
useRef,
useEffect,
useCallback,
useMemo,
useState,
useEffect,
forwardRef,
useReducer,
useCallback,
} from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import CodeInterpreter from './CodeInterpreter';
import { BadgeRowProvider } from '~/Providers';
import ToolsDropdown from './ToolsDropdown';
import type { BadgeItem } from '~/common';
import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import ToolDialogs from './ToolDialogs';
import FileSearch from './FileSearch';
import MCPSelect from './MCPSelect';
import WebSearch from './WebSearch';
import store from '~/store';
@ -313,78 +317,83 @@ function BadgeRow({
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
return (
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<WebSearch conversationId={conversationId} />
<CodeInterpreter conversationId={conversationId} />
<MCPSelect conversationId={conversationId} />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
<BadgeRowProvider conversationId={conversationId}>
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{showEphemeralBadges === true && <ToolsDropdown />}
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<WebSearch />
<CodeInterpreter />
<FileSearch />
<MCPSelect />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
<ToolDialogs />
</BadgeRowProvider>
);
}

View file

@ -1,122 +1,37 @@
import debounce from 'lodash/debounce';
import React, { memo, useMemo, useCallback, useRef } from 'react';
import { useRecoilState } from 'recoil';
import React, { memo } from 'react';
import { TerminalSquareIcon } from 'lucide-react';
import {
Tools,
AuthType,
Constants,
LocalStorageKeys,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import CheckboxButton from '~/components/ui/CheckboxButton';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null && value !== '' && value !== false;
};
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
const triggerRef = useRef<HTMLInputElement>(null);
function CodeInterpreter() {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const { codeInterpreter, codeApiKeyForm } = useBadgeRowContext();
const { toggleState: runCode, debouncedChange, isPinned } = codeInterpreter;
const { badgeTriggerRef } = codeApiKeyForm;
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const isCodeToggleEnabled = useMemo(() => {
return ephemeralAgent?.execute_code ?? false;
}, [ephemeralAgent?.execute_code]);
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.execute_code },
{
retry: 1,
},
);
const authType = useMemo(() => data?.message ?? false, [data?.message]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useCodeApiKeyForm({});
const setValue = useCallback(
(isChecked: boolean) => {
setEphemeralAgent((prev) => ({
...prev,
execute_code: isChecked,
}));
},
[setEphemeralAgent],
);
const [runCode, setRunCode] = useLocalStorage<boolean>(
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
isCodeToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
if (!isAuthenticated) {
setIsDialogOpen(true);
e.preventDefault();
return;
}
setRunCode(isChecked);
},
[setRunCode, setIsDialogOpen, isAuthenticated],
);
const debouncedChange = useMemo(
() => debounce(handleChange, 50, { leading: true }),
[handleChange],
);
if (!canRunCode) {
return null;
}
return (
<>
(runCode || isPinned) && (
<CheckboxButton
ref={triggerRef}
ref={badgeTriggerRef}
className="max-w-fit"
defaultChecked={runCode}
checked={runCode}
setValue={debouncedChange}
label={localize('com_assistants_code_interpreter')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
icon={<TerminalSquareIcon className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}
triggerRef={triggerRef}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
)
);
}

View file

@ -0,0 +1,28 @@
import React, { memo } from 'react';
import CheckboxButton from '~/components/ui/CheckboxButton';
import { useBadgeRowContext } from '~/Providers';
import { VectorIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
function FileSearch() {
const localize = useLocalize();
const { fileSearch } = useBadgeRowContext();
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = fileSearch;
return (
<>
{(fileSearchEnabled || isPinned) && (
<CheckboxButton
className="max-w-fit"
checked={fileSearchEnabled}
setValue={debouncedChange}
label={localize('com_assistants_file_search')}
isCheckedClassName="border-green-600/40 bg-green-500/10 hover:bg-green-700/10"
icon={<VectorIcon className="icon-md" />}
/>
)}
</>
);
}
export default memo(FileSearch);

View file

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

View file

@ -1,25 +1,32 @@
import { useSetRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { useLocalize, useFileHandling } from '~/hooks';
import { ephemeralAgentByConvoId } from '~/store';
import { cn } from '~/utils';
interface AttachFileProps {
interface AttachFileMenuProps {
conversationId: string;
disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
}
const AttachFile = ({ disabled }: AttachFileProps) => {
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
/** TODO: Ephemeral Agent Capabilities
@ -69,6 +76,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
label: localize('com_ui_upload_file_search'),
onClick: () => {
setToolResource(EToolResources.file_search);
/** File search is not automatically enabled to simulate legacy behavior */
handleUploadClick();
},
icon: <FileSearch className="icon-md" />,
@ -80,6 +88,10 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
label: localize('com_ui_upload_code_files'),
onClick: () => {
setToolResource(EToolResources.execute_code);
setEphemeralAgent((prev) => ({
...prev,
[EToolResources.execute_code]: true,
}));
handleUploadClick();
},
icon: <TerminalSquareIcon className="icon-md" />,
@ -87,7 +99,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
}
return items;
}, [capabilities, localize, setToolResource]);
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
const menuTrigger = (
<TooltipAnchor
@ -132,4 +144,4 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
);
};
export default React.memo(AttachFile);
export default React.memo(AttachFileMenu);

View file

@ -7,7 +7,7 @@ import useLocalize from '~/hooks/useLocalize';
import { OGDialog } from '~/components/ui';
interface DragDropModalProps {
onOptionSelect: (option: string | undefined) => void;
onOptionSelect: (option: EToolResources | undefined) => void;
files: File[];
isVisible: boolean;
setShowModal: (showModal: boolean) => void;

View file

@ -1,88 +1,42 @@
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import { useAvailableToolsQuery } from '~/data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import React, { memo, useCallback, useState } from 'react';
import { SettingsIcon } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
import { useToastContext, useBadgeRowContext } from '~/Providers';
import MultiSelect from '~/components/ui/MultiSelect';
import { ephemeralAgentByConvoId } from '~/store';
import MCPIcon from '~/components/ui/MCPIcon';
import { MCPIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue.length > 2) {
return true;
}
} catch (e) {
console.error(e);
}
}
return Array.isArray(value) && value.length > 0;
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
const parts = fullPluginKey.split(Constants.mcp_delimiter);
return Constants.mcp_prefix + parts[parts.length - 1];
};
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
function MCPSelect() {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const hasSetFetched = useRef<string | null>(null);
const { showToast } = useToastContext();
const { mcpSelect, startupConfig } = useBadgeRowContext();
const { mcpValues, setMCPValues, mcpServerNames, mcpToolDetails, isPinned } = mcpSelect;
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data) => {
const serverNames = new Set<string>();
data.forEach((tool) => {
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
if (isMCP && tool.chatMenu !== false) {
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
serverNames.add(parts[parts.length - 1]);
}
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: () => {
setIsConfigModalOpen(false);
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
},
onError: (error: unknown) => {
console.error('Error updating MCP auth:', error);
showToast({
message: localize('com_nav_mcp_vars_update_error'),
status: 'error',
});
return serverNames;
},
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const mcpState = useMemo(() => {
return ephemeralAgent?.mcp ?? [];
}, [ephemeralAgent?.mcp]);
const setSelectedValues = useCallback(
(values: string[] | null | undefined) => {
if (!values) {
return;
}
if (!Array.isArray(values)) {
return;
}
setEphemeralAgent((prev) => ({
...prev,
mcp: values,
}));
},
[setEphemeralAgent],
);
const [mcpValues, setMCPValues] = useLocalStorage<string[]>(
`${LocalStorageKeys.LAST_MCP_}${key}`,
mcpState,
setSelectedValues,
storageCondition,
);
useEffect(() => {
if (hasSetFetched.current === key) {
return;
}
if (!isFetched) {
return;
}
hasSetFetched.current = key;
if ((mcpServerSet?.size ?? 0) > 0) {
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
return;
}
setMCPValues([]);
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
const renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
if (values.length === 0) {
@ -96,28 +50,143 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
[localize],
);
const mcpServers = useMemo(() => {
return Array.from(mcpServerSet ?? []);
}, [mcpServerSet]);
const handleConfigSave = useCallback(
(targetName: string, authData: Record<string, string>) => {
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
if (!mcpServerSet || mcpServerSet.size === 0) {
const payload: TUpdateUserPlugins = {
pluginKey: basePluginKey,
action: 'install',
auth: authData,
};
updateUserPluginsMutation.mutate(payload);
}
},
[selectedToolForConfig, updateUserPluginsMutation],
);
const handleConfigRevoke = useCallback(
(targetName: string) => {
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
const payload: TUpdateUserPlugins = {
pluginKey: basePluginKey,
action: 'uninstall',
auth: {},
};
updateUserPluginsMutation.mutate(payload);
}
},
[selectedToolForConfig, updateUserPluginsMutation],
);
const renderItemContent = useCallback(
(serverName: string, defaultContent: React.ReactNode) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName);
const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0;
// Common wrapper for the main content (check mark + text)
// Ensures Check & Text are adjacent and the group takes available space.
const mainContentWrapper = (
<div className="flex flex-grow items-center">{defaultContent}</div>
);
if (tool && hasAuthConfig) {
return (
<div className="flex w-full items-center justify-between">
{mainContentWrapper}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setSelectedToolForConfig(tool);
setIsConfigModalOpen(true);
}}
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={`Configure ${serverName}`}
>
<SettingsIcon className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
</button>
</div>
);
}
// For items without a settings icon, return the consistently wrapped main content.
return mainContentWrapper;
},
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
);
// Don't render if no servers are selected and not pinned
if ((!mcpValues || mcpValues.length === 0) && !isPinned) {
return null;
}
if (!mcpToolDetails || mcpToolDetails.length === 0) {
return null;
}
const placeholderText =
startupConfig?.interface?.mcpServers?.placeholder || localize('com_ui_mcp_servers');
return (
<MultiSelect
items={mcpServers ?? []}
selectedValues={mcpValues ?? []}
setSelectedValues={setMCPValues}
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
placeholder={localize('com_ui_mcp_servers')}
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="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"
/>
<>
<MultiSelect
items={mcpServerNames}
selectedValues={mcpValues ?? []}
setSelectedValues={setMCPValues}
defaultSelectedValues={mcpValues ?? []}
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="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"
/>
{selectedToolForConfig && (
<MCPConfigDialog
isOpen={isConfigModalOpen}
onOpenChange={setIsConfigModalOpen}
serverName={selectedToolForConfig.name}
fieldsSchema={(() => {
const schema: Record<string, ConfigFieldDetail> = {};
if (selectedToolForConfig?.authConfig) {
selectedToolForConfig.authConfig.forEach((field) => {
schema[field.authField] = {
title: field.label,
description: field.description,
};
});
}
return schema;
})()}
initialValues={(() => {
const initial: Record<string, string> = {};
// Note: Actual initial values might need to be fetched if they are stored user-specifically
if (selectedToolForConfig?.authConfig) {
selectedToolForConfig.authConfig.forEach((field) => {
initial[field.authField] = ''; // Or fetched value
});
}
return initial;
})()}
onSave={(authData) => {
if (selectedToolForConfig) {
handleConfigSave(selectedToolForConfig.name, authData);
}
}}
onRevoke={() => {
if (selectedToolForConfig) {
handleConfigRevoke(selectedToolForConfig.name);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
)}
</>
);
}

View file

@ -0,0 +1,103 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPSubMenuProps {
isMCPPinned: boolean;
setIsMCPPinned: (value: boolean) => void;
mcpValues?: string[];
mcpServerNames: string[];
handleMCPToggle: (serverName: string) => void;
placeholder?: string;
}
const MCPSubMenu = ({
mcpValues,
isMCPPinned,
mcpServerNames,
setIsMCPPinned,
handleMCPToggle,
placeholder,
...props
}: MCPSubMenuProps) => {
const localize = useLocalize();
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
return (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{placeholder || localize('com_ui_mcp_servers')}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsMCPPinned(!isMCPPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isMCPPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isMCPPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isMCPPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
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',
)}
>
{mcpServerNames.map((serverName) => (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
handleMCPToggle(serverName);
}}
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 text-sm',
)}
>
<Ariakit.MenuItemCheck checked={mcpValues?.includes(serverName) ?? false} />
<span>{serverName}</span>
</Ariakit.MenuItem>
))}
</Ariakit.Menu>
</Ariakit.MenuProvider>
);
};
export default React.memo(MCPSubMenu);

View file

@ -0,0 +1,66 @@
import React, { useMemo } from 'react';
import { AuthType } from 'librechat-data-provider';
import SearchApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog';
import CodeApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useBadgeRowContext } from '~/Providers';
function ToolDialogs() {
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = useBadgeRowContext();
const { authData: webSearchAuthData } = webSearch;
const { authData: codeAuthData } = codeInterpreter;
const {
methods: searchMethods,
onSubmit: searchOnSubmit,
isDialogOpen: searchDialogOpen,
setIsDialogOpen: setSearchDialogOpen,
handleRevokeApiKey: searchHandleRevoke,
badgeTriggerRef: searchBadgeTriggerRef,
menuTriggerRef: searchMenuTriggerRef,
} = searchApiKeyForm;
const {
methods: codeMethods,
onSubmit: codeOnSubmit,
isDialogOpen: codeDialogOpen,
setIsDialogOpen: setCodeDialogOpen,
handleRevokeApiKey: codeHandleRevoke,
badgeTriggerRef: codeBadgeTriggerRef,
menuTriggerRef: codeMenuTriggerRef,
} = codeApiKeyForm;
const searchAuthTypes = useMemo(
() => webSearchAuthData?.authTypes ?? [],
[webSearchAuthData?.authTypes],
);
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
return (
<>
<SearchApiKeyDialog
onSubmit={searchOnSubmit}
authTypes={searchAuthTypes}
isOpen={searchDialogOpen}
onRevoke={searchHandleRevoke}
register={searchMethods.register}
onOpenChange={setSearchDialogOpen}
handleSubmit={searchMethods.handleSubmit}
triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]}
isToolAuthenticated={webSearchAuthData?.authenticated ?? false}
/>
<CodeApiKeyDialog
onSubmit={codeOnSubmit}
isOpen={codeDialogOpen}
onRevoke={codeHandleRevoke}
register={codeMethods.register}
onOpenChange={setCodeDialogOpen}
handleSubmit={codeMethods.handleSubmit}
triggerRefs={[codeMenuTriggerRef, codeBadgeTriggerRef]}
isUserProvided={codeAuthType === AuthType.USER_PROVIDED}
isToolAuthenticated={codeAuthData?.authenticated ?? false}
/>
</>
);
}
export default ToolDialogs;

View file

@ -0,0 +1,323 @@
import React, { useState, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
import type { MenuItemProps } from '~/common';
import { Permissions, PermissionTypes, AuthType } from 'librechat-data-provider';
import { TooltipAnchor, DropdownPopup } from '~/components';
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
import { PinIcon, VectorIcon } from '~/components/svg';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
import { cn } from '~/utils';
interface ToolsDropdownProps {
disabled?: boolean;
}
const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const localize = useLocalize();
const isDisabled = disabled ?? false;
const [isPopoverActive, setIsPopoverActive] = useState(false);
const {
webSearch,
mcpSelect,
fileSearch,
startupConfig,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = useBadgeRowContext();
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm;
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
searchApiKeyForm;
const {
isPinned: isSearchPinned,
setIsPinned: setIsSearchPinned,
authData: webSearchAuthData,
} = webSearch;
const {
isPinned: isCodePinned,
setIsPinned: setIsCodePinned,
authData: codeAuthData,
} = codeInterpreter;
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
const {
mcpValues,
mcpServerNames,
isPinned: isMCPPinned,
setIsPinned: setIsMCPPinned,
} = mcpSelect;
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const showWebSearchSettings = useMemo(() => {
const authTypes = webSearchAuthData?.authTypes ?? [];
if (authTypes.length === 0) return true;
return !authTypes.every(([, authType]) => authType === AuthType.SYSTEM_DEFINED);
}, [webSearchAuthData?.authTypes]);
const showCodeSettings = useMemo(
() => codeAuthData?.message !== AuthType.SYSTEM_DEFINED,
[codeAuthData?.message],
);
const handleWebSearchToggle = useCallback(() => {
const newValue = !webSearch.toggleState;
webSearch.debouncedChange({ isChecked: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ isChecked: newValue });
}, [codeInterpreter]);
const handleFileSearchToggle = useCallback(() => {
const newValue = !fileSearch.toggleState;
fileSearch.debouncedChange({ isChecked: newValue });
}, [fileSearch]);
const handleMCPToggle = useCallback(
(serverName: string) => {
const currentValues = mcpSelect.mcpValues ?? [];
const newValues = currentValues.includes(serverName)
? currentValues.filter((v) => v !== serverName)
: [...currentValues, serverName];
mcpSelect.setMCPValues(newValues);
},
[mcpSelect],
);
const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder;
const dropdownItems = useMemo(() => {
const items: MenuItemProps[] = [];
items.push({
onClick: handleFileSearchToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<VectorIcon className="icon-md" />
<span>{localize('com_assistants_file_search')}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsFileSearchPinned(!isFileSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isFileSearchPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isFileSearchPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isFileSearchPinned} />
</div>
</button>
</div>
),
});
if (canUseWebSearch) {
items.push({
onClick: handleWebSearchToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<Globe className="icon-md" />
<span>{localize('com_ui_web_search')}</span>
</div>
<div className="flex items-center gap-1">
{showWebSearchSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchDialogOpen(true);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
'text-text-secondary hover:text-text-primary',
)}
aria-label="Configure web search"
ref={searchMenuTriggerRef}
>
<div className="h-4 w-4">
<Settings className="h-4 w-4" />
</div>
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchPinned(!isSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isSearchPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isSearchPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isSearchPinned} />
</div>
</button>
</div>
</div>
),
});
}
if (canRunCode) {
items.push({
onClick: handleCodeInterpreterToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<TerminalSquareIcon className="icon-md" />
<span>{localize('com_assistants_code_interpreter')}</span>
</div>
<div className="flex items-center gap-1">
{showCodeSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodeDialogOpen(true);
}}
ref={codeMenuTriggerRef}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
'text-text-secondary hover:text-text-primary',
)}
aria-label="Configure code interpreter"
>
<div className="h-4 w-4">
<Settings className="h-4 w-4" />
</div>
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodePinned(!isCodePinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isCodePinned && 'text-text-primary hover:text-text-primary',
)}
aria-label={isCodePinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isCodePinned} />
</div>
</button>
</div>
</div>
),
});
}
if (mcpServerNames && mcpServerNames.length > 0) {
items.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu
{...props}
mcpValues={mcpValues}
isMCPPinned={isMCPPinned}
placeholder={mcpPlaceholder}
mcpServerNames={mcpServerNames}
setIsMCPPinned={setIsMCPPinned}
handleMCPToggle={handleMCPToggle}
/>
),
});
}
return items;
}, [
localize,
mcpValues,
canRunCode,
isMCPPinned,
isCodePinned,
mcpPlaceholder,
mcpServerNames,
isSearchPinned,
setIsMCPPinned,
canUseWebSearch,
setIsCodePinned,
handleMCPToggle,
showCodeSettings,
setIsSearchPinned,
isFileSearchPinned,
codeMenuTriggerRef,
setIsCodeDialogOpen,
searchMenuTriggerRef,
showWebSearchSettings,
setIsFileSearchPinned,
handleWebSearchToggle,
setIsSearchDialogOpen,
handleFileSearchToggle,
handleCodeInterpreterToggle,
]);
const menuTrigger = (
<TooltipAnchor
render={
<Ariakit.MenuButton
disabled={isDisabled}
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',
)}
>
<div className="flex w-full items-center justify-center gap-2">
<Settings2 className="icon-md" />
</div>
</Ariakit.MenuButton>
}
id="tools-dropdown-button"
description={localize('com_ui_tools')}
disabled={isDisabled}
/>
);
return (
<DropdownPopup
itemClassName="flex w-full cursor-pointer items-center justify-between hover:bg-surface-hover gap-5"
menuId="tools-dropdown-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
unmountOnHide={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
);
};
export default React.memo(ToolsDropdown);

View file

@ -1,122 +1,37 @@
import React, { memo, useRef, useMemo, useCallback } from 'react';
import React, { memo } from 'react';
import { Globe } from 'lucide-react';
import debounce from 'lodash/debounce';
import { useRecoilState } from 'recoil';
import {
Tools,
AuthType,
Constants,
Permissions,
PermissionTypes,
LocalStorageKeys,
} from 'librechat-data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog';
import { useLocalize, useHasAccess, useSearchApiKeyForm } from '~/hooks';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import CheckboxButton from '~/components/ui/CheckboxButton';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null && value !== '' && value !== false;
};
function WebSearch({ conversationId }: { conversationId?: string | null }) {
const triggerRef = useRef<HTMLInputElement>(null);
function WebSearch() {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
const { toggleState: webSearch, debouncedChange, isPinned } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const isWebSearchToggleEnabled = useMemo(() => {
return ephemeralAgent?.web_search ?? false;
}, [ephemeralAgent?.web_search]);
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.web_search },
{
retry: 1,
},
);
const authTypes = useMemo(() => data?.authTypes ?? [], [data?.authTypes]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useSearchApiKeyForm({});
const setValue = useCallback(
(isChecked: boolean) => {
setEphemeralAgent((prev) => ({
...prev,
web_search: isChecked,
}));
},
[setEphemeralAgent],
);
const [webSearch, setWebSearch] = useLocalStorage<boolean>(
`${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`,
isWebSearchToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
if (!isAuthenticated) {
setIsDialogOpen(true);
e.preventDefault();
return;
}
setWebSearch(isChecked);
},
[setWebSearch, setIsDialogOpen, isAuthenticated],
);
const debouncedChange = useMemo(
() => debounce(handleChange, 50, { leading: true }),
[handleChange],
);
if (!canUseWebSearch) {
return null;
}
return (
<>
(webSearch || isPinned) && (
<CheckboxButton
ref={triggerRef}
ref={badgeTriggerRef}
className="max-w-fit"
defaultChecked={webSearch}
checked={webSearch}
setValue={debouncedChange}
label={localize('com_ui_search')}
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"
icon={<Globe className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
authTypes={authTypes}
isOpen={isDialogOpen}
triggerRef={triggerRef}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
/>
</>
)
);
}

View file

@ -157,8 +157,9 @@ const BookmarkMenu: FC = () => {
return (
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
<DropdownPopup
focusLoop={true}
portal={true}
menuId={menuId}
focusLoop={true}
isOpen={isMenuOpen}
unmountOnHide={true}
setIsOpen={setIsMenuOpen}

View file

@ -159,7 +159,7 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
blurOnHoverEnd: false,
...props,
className: cn(
'flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full',
'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',
props.className,
),
};

View file

@ -102,12 +102,12 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
if (endpoint.hasModels) {
const filteredModels = searchValue
? filterModels(
endpoint,
(endpoint.models || []).map((model) => model.name),
searchValue,
agentsMap,
assistantsMap,
)
endpoint,
(endpoint.models || []).map((model) => model.name),
searchValue,
agentsMap,
assistantsMap,
)
: null;
const placeholder =
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)

View file

@ -45,10 +45,10 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
</div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
endpoint.icon ? (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
) : null}
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
) : null}
<span>{modelName}</span>
</div>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}

View file

@ -102,22 +102,22 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery)
? endpoint.models
: endpoint.models.filter((model) => {
let modelName = model.name;
if (
isAgentsEndpoint(endpoint.value) &&
let modelName = model.name;
if (
isAgentsEndpoint(endpoint.value) &&
endpoint.agentNames &&
endpoint.agentNames[model.name]
) {
modelName = endpoint.agentNames[model.name];
} else if (
isAssistantsEndpoint(endpoint.value) &&
) {
modelName = endpoint.agentNames[model.name];
} else if (
isAssistantsEndpoint(endpoint.value) &&
endpoint.assistantNames &&
endpoint.assistantNames[model.name]
) {
modelName = endpoint.assistantNames[model.name];
}
return modelName.toLowerCase().includes(lowerQuery);
});
) {
modelName = endpoint.assistantNames[model.name];
}
return modelName.toLowerCase().includes(lowerQuery);
});
if (!filteredModels.length) {
return null; // skip if no models match

View file

@ -83,7 +83,7 @@ export function filterModels(
let modelName = modelId;
if (isAgentsEndpoint(endpoint.value) && agentsMap && agentsMap[modelId]) {
modelName = agentsMap[modelId].name || modelId;
modelName = agentsMap[modelId]?.name || modelId;
} else if (
isAssistantsEndpoint(endpoint.value) &&
assistantsMap &&

View file

@ -9,6 +9,7 @@ import type {
} from 'librechat-data-provider';
import { ThinkingButton } from '~/components/Artifacts/Thinking';
import { MessageContext, SearchContext } from '~/Providers';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import useLocalize from '~/hooks/useLocalize';
import { mapAttachments } from '~/utils/map';
@ -72,6 +73,7 @@ const ContentParts = memo(
return hasThinkPart && allThinkPartsHaveContent;
}, [content]);
if (!content) {
return null;
}
@ -79,14 +81,23 @@ const ContentParts = memo(
return (
<>
{content.map((part, idx) => {
if (part?.type !== ContentTypes.TEXT || typeof part.text !== 'string') {
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;
}
return (
<EditTextPart
index={idx}
text={part.text}
part={part as Agents.MessageContentText | Agents.ReasoningDeltaUpdate}
messageId={messageId}
isSubmitting={isSubmitting}
enterEdit={enterEdit}
@ -103,6 +114,7 @@ const ContentParts = memo(
return (
<>
<SearchContext.Provider value={{ searchResults }}>
<MemoryArtifacts attachments={attachments} />
<Sources />
{hasReasoningParts && (
<div className="mb-5">

View file

@ -1,7 +1,185 @@
import { X, ArrowDownToLine } from 'lucide-react';
import { Button, OGDialog, OGDialogContent } from '~/components';
import { useState, useEffect, useCallback, useRef } from 'react';
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
import { Button, OGDialog, OGDialogContent, TooltipAnchor } from '~/components';
import { useLocalize } from '~/hooks';
const getQualityStyles = (quality: string): string => {
if (quality === 'high') {
return 'bg-green-100 text-green-800';
}
if (quality === 'low') {
return 'bg-orange-100 text-orange-800';
}
return 'bg-gray-100 text-gray-800';
};
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage, args }) {
const localize = useLocalize();
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [imageSize, setImageSize] = useState<string | null>(null);
// Zoom and pan state
const [zoom, setZoom] = useState(1);
const [panX, setPanX] = useState(0);
const [panY, setPanY] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const getImageSize = useCallback(async (url: string) => {
try {
const response = await fetch(url, { method: 'HEAD' });
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
const bytes = parseInt(contentLength, 10);
return formatFileSize(bytes);
}
const fullResponse = await fetch(url);
const blob = await fullResponse.blob();
return formatFileSize(blob.size);
} catch (error) {
console.error('Error getting image size:', error);
return null;
}
}, []);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getImageMaxWidth = () => {
// On mobile (when panel overlays), use full width minus padding
// On desktop, account for the side panel width
if (isPromptOpen) {
return window.innerWidth >= 640 ? 'calc(100vw - 22rem)' : 'calc(100vw - 2rem)';
}
return 'calc(100vw - 2rem)';
};
const resetZoom = useCallback(() => {
setZoom(1);
setPanX(0);
setPanY(0);
}, []);
const getCursor = () => {
if (zoom <= 1) return 'default';
return isDragging ? 'grabbing' : 'grab';
};
const handleDoubleClick = useCallback(() => {
if (zoom > 1) {
resetZoom();
} else {
// Zoom in to 2x on double click when at normal zoom
setZoom(2);
}
}, [zoom, resetZoom]);
const handleWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate zoom factor
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(Math.max(zoom * zoomFactor, 1), 5);
if (newZoom === zoom) return;
// If zooming back to 1, reset pan to center the image
if (newZoom === 1) {
setZoom(1);
setPanX(0);
setPanY(0);
return;
}
// Calculate the zoom center relative to the current viewport
const containerCenterX = rect.width / 2;
const containerCenterY = rect.height / 2;
// Calculate new pan position to zoom towards mouse cursor
const zoomRatio = newZoom / zoom;
const deltaX = (mouseX - containerCenterX - panX) * (zoomRatio - 1);
const deltaY = (mouseY - containerCenterY - panY) * (zoomRatio - 1);
setZoom(newZoom);
setPanX(panX - deltaX);
setPanY(panY - deltaY);
},
[zoom, panX, panY],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
if (zoom <= 1) return;
setIsDragging(true);
setDragStart({
x: e.clientX - panX,
y: e.clientY - panY,
});
},
[zoom, panX, panY],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || zoom <= 1) return;
const newPanX = e.clientX - dragStart.x;
const newPanY = e.clientY - dragStart.y;
setPanX(newPanX);
setPanY(newPanY);
},
[isDragging, dragStart, zoom],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && resetZoom();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [resetZoom]);
useEffect(() => {
if (isOpen && src) {
getImageSize(src).then(setImageSize);
resetZoom();
}
}, [isOpen, src, getImageSize, resetZoom]);
// Ensure image is centered when zoom changes to 1
useEffect(() => {
if (zoom === 1) {
setPanX(0);
setPanY(0);
}
}, [zoom]);
// Reset pan when panel opens/closes to maintain centering
useEffect(() => {
if (zoom === 1) {
setPanX(0);
setPanY(0);
}
}, [isPromptOpen, zoom]);
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage }) {
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
@ -10,32 +188,185 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
disableScroll={false}
overlayClassName="bg-surface-primary opacity-95 z-50"
>
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover"
>
<X className="size-6" />
</Button>
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
<ArrowDownToLine className="size-6" />
</Button>
</div>
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent
showCloseButton={false}
className="w-11/12 overflow-x-auto rounded-none bg-transparent p-4 shadow-none sm:w-auto"
disableScroll={false}
overlayClassName="bg-transparent"
>
<img
src={src}
alt="Uploaded image"
className="max-w-screen h-full max-h-screen w-full object-contain"
<div
className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] absolute left-0 top-0 z-10 flex items-center justify-between p-3 transition-all duration-500 sm:p-4 ${isPromptOpen ? 'right-0 sm:right-80' : 'right-0'}`}
>
<TooltipAnchor
description={localize('com_ui_close')}
render={
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover"
>
<X className="size-7 sm:size-6" />
</Button>
}
/>
<div className="flex items-center gap-1 sm:gap-2">
{zoom > 1 && (
<TooltipAnchor
description={localize('com_ui_reset_zoom')}
render={
<Button onClick={resetZoom} variant="ghost" className="h-10 w-10 p-0">
<RotateCcw className="size-6" />
</Button>
}
/>
)}
<TooltipAnchor
description={localize('com_ui_download')}
render={
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
<ArrowDownToLine className="size-6" />
</Button>
}
/>
</OGDialogContent>
</OGDialog>
<TooltipAnchor
description={
isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details')
}
render={
<Button
onClick={() => setIsPromptOpen(!isPromptOpen)}
variant="ghost"
className="h-10 w-10 p-0"
>
{isPromptOpen ? (
<PanelLeftOpen className="size-7 sm:size-6" />
) : (
<PanelLeftClose className="size-7 sm:size-6" />
)}
</Button>
}
/>
</div>
</div>
{/* Main content area with image */}
<div
className={`ease-[cubic-bezier(0.175,0.885,0.32,1.275)] flex h-full transition-all duration-500 ${isPromptOpen ? 'mr-0 sm:mr-80' : 'mr-0'}`}
>
<div
ref={containerRef}
className="flex flex-1 items-center justify-center px-2 pb-4 pt-16 sm:px-4 sm:pt-20"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleDoubleClick}
style={{
cursor: getCursor(),
overflow: zoom > 1 ? 'hidden' : 'visible',
minHeight: 0, // Allow flexbox to shrink
}}
>
<div
className="flex items-center justify-center transition-transform duration-100 ease-out"
style={{
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
transformOrigin: 'center center',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={src}
alt="Image"
className="block object-contain"
style={{
maxHeight: 'calc(100vh - 8rem)',
maxWidth: getImageMaxWidth(),
width: 'auto',
height: 'auto',
}}
/>
</div>
</div>
</div>
{/* Side Panel */}
<div
className={`sm:shadow-l-lg ease-[cubic-bezier(0.175,0.885,0.32,1.275)] fixed right-0 top-0 z-20 h-full w-full transform border-l border-border-light bg-surface-primary shadow-2xl backdrop-blur-sm transition-transform duration-500 sm:w-80 sm:rounded-l-2xl ${
isPromptOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Mobile pull handle - removed for cleaner look */}
<div className="h-full overflow-y-auto p-4 sm:p-6">
{/* Mobile close button */}
<div className="mb-4 flex items-center justify-between sm:hidden">
<h3 className="text-lg font-semibold text-text-primary">
{localize('com_ui_image_details')}
</h3>
<Button
onClick={() => setIsPromptOpen(false)}
variant="ghost"
className="h-12 w-12 p-0"
>
<X className="size-6" />
</Button>
</div>
<div className="mb-4 hidden sm:block">
<h3 className="mb-2 text-lg font-semibold text-text-primary">
{localize('com_ui_image_details')}
</h3>
<div className="mb-4 h-px bg-border-medium"></div>
</div>
<div className="space-y-4 sm:space-y-6">
{/* Prompt Section */}
<div>
<h4 className="mb-2 text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</h4>
<div className="rounded-md bg-surface-tertiary p-3">
<p className="text-sm leading-relaxed text-text-primary">
{args?.prompt || 'No prompt available'}
</p>
</div>
</div>
{/* Generation Settings */}
<div>
<h4 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_generation_settings')}
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">{localize('com_ui_size')}:</span>
<span className="text-sm font-medium text-text-primary">
{args?.size || 'Unknown'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">{localize('com_ui_quality')}:</span>
<span
className={`rounded px-2 py-1 text-xs font-medium capitalize ${getQualityStyles(args?.quality || '')}`}
>
{args?.quality || 'Standard'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-text-primary">
{localize('com_ui_file_size')}:
</span>
<span className="text-sm font-medium text-text-primary">
{imageSize || 'Loading...'}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
);

View file

@ -11,6 +11,7 @@ const Image = ({
width,
placeholderDimensions,
className,
args,
}: {
imagePath: string;
altText: string;
@ -21,6 +22,13 @@ const Image = ({
width?: string;
};
className?: string;
args?: {
prompt?: string;
quality?: 'low' | 'medium' | 'high';
size?: string;
style?: string;
[key: string]: unknown;
};
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
@ -38,13 +46,33 @@ const Image = ({
[placeholderDimensions, height, width],
);
const downloadImage = () => {
const link = document.createElement('a');
link.href = imagePath;
link.download = altText;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const downloadImage = async () => {
try {
const response = await fetch(imagePath);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Download failed:', error);
const link = document.createElement('a');
link.href = imagePath;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
return (
@ -91,6 +119,7 @@ const Image = ({
onOpenChange={setIsOpen}
src={imagePath}
downloadImage={downloadImage}
args={args}
/>
)}
</div>

View file

@ -204,7 +204,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
remarkGfm,
remarkDirective,
artifactPlugin,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
unicodeCitation,
];

View file

@ -32,7 +32,7 @@ const MarkdownLite = memo(
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}

View file

@ -0,0 +1,143 @@
import { Tools } from 'librechat-data-provider';
import { useState, useRef, useMemo, useLayoutEffect, useEffect } from 'react';
import type { MemoryArtifact, TAttachment } from 'librechat-data-provider';
import MemoryInfo from './MemoryInfo';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function MemoryArtifacts({ attachments }: { attachments?: TAttachment[] }) {
const localize = useLocalize();
const [showInfo, setShowInfo] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [isAnimating, setIsAnimating] = useState(false);
const prevShowInfoRef = useRef<boolean>(showInfo);
const memoryArtifacts = useMemo(() => {
const result: MemoryArtifact[] = [];
for (const attachment of attachments ?? []) {
if (attachment?.[Tools.memory] != null) {
result.push(attachment[Tools.memory]);
}
}
return result;
}, [attachments]);
useLayoutEffect(() => {
if (showInfo !== prevShowInfoRef.current) {
prevShowInfoRef.current = showInfo;
setIsAnimating(true);
if (showInfo && contentRef.current) {
requestAnimationFrame(() => {
if (contentRef.current) {
const height = contentRef.current.scrollHeight;
setContentHeight(height + 4);
}
});
} else {
setContentHeight(0);
}
const timer = setTimeout(() => {
setIsAnimating(false);
}, 400);
return () => clearTimeout(timer);
}
}, [showInfo]);
useEffect(() => {
if (!contentRef.current) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
if (showInfo && !isAnimating) {
for (const entry of entries) {
if (entry.target === contentRef.current) {
setContentHeight(entry.contentRect.height + 4);
}
}
}
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [showInfo, isAnimating]);
if (!memoryArtifacts || memoryArtifacts.length === 0) {
return null;
}
return (
<>
<div className="flex items-center">
<div className="inline-block">
<button
className="outline-hidden my-1 flex items-center gap-1 text-sm font-semibold text-text-secondary-alt transition-colors hover:text-text-primary"
type="button"
onClick={() => setShowInfo((prev) => !prev)}
aria-expanded={showInfo}
aria-label={localize('com_ui_memory_updated')}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mb-[-1px]"
>
<path
d="M6 3C4.89543 3 4 3.89543 4 5V13C4 14.1046 4.89543 15 6 15L6 3Z"
fill="currentColor"
/>
<path
d="M7 3V15H8.18037L8.4899 13.4523C8.54798 13.1619 8.69071 12.8952 8.90012 12.6858L12.2931 9.29289C12.7644 8.82153 13.3822 8.58583 14 8.58578V3.5C14 3.22386 13.7761 3 13.5 3H7Z"
fill="currentColor"
/>
<path
d="M11.3512 15.5297L9.73505 15.8529C9.38519 15.9229 9.07673 15.6144 9.14671 15.2646L9.46993 13.6484C9.48929 13.5517 9.53687 13.4628 9.60667 13.393L12.9996 10C13.5519 9.44771 14.4473 9.44771 14.9996 10C15.5519 10.5523 15.5519 11.4477 14.9996 12L11.6067 15.393C11.5369 15.4628 11.448 15.5103 11.3512 15.5297Z"
fill="currentColor"
/>
</svg>
{localize('com_ui_memory_updated')}
</button>
</div>
</div>
<div
className="relative"
style={{
height: showInfo ? contentHeight : 0,
overflow: 'hidden',
transition:
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
opacity: showInfo ? 1 : 0,
transformOrigin: 'top',
willChange: 'height, opacity',
perspective: '1000px',
backfaceVisibility: 'hidden',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<div
className={cn(
'overflow-hidden rounded-xl border border-border-light bg-surface-primary-alt shadow-md',
showInfo && 'shadow-lg',
)}
style={{
transform: showInfo ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
opacity: showInfo ? 1 : 0,
transition:
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
<div ref={contentRef}>
{showInfo && <MemoryInfo key="memory-info" memoryArtifacts={memoryArtifacts} />}
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,61 @@
import type { MemoryArtifact } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: MemoryArtifact[] }) {
const localize = useLocalize();
if (memoryArtifacts.length === 0) {
return null;
}
// Group artifacts by type
const updatedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'update');
const deletedMemories = memoryArtifacts.filter((artifact) => artifact.type === 'delete');
if (updatedMemories.length === 0 && deletedMemories.length === 0) {
return null;
}
return (
<div className="space-y-4 p-4">
{updatedMemories.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold text-text-primary">
{localize('com_ui_memory_updated_items')}
</h4>
<div className="space-y-2">
{updatedMemories.map((artifact, index) => (
<div key={`update-${index}`} className="rounded-lg p-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{artifact.key}
</div>
<div className="whitespace-pre-wrap text-sm text-text-primary">
{artifact.value}
</div>
</div>
))}
</div>
</div>
)}
{deletedMemories.length > 0 && (
<div>
<h4 className="mb-2 text-sm font-semibold text-text-primary">
{localize('com_ui_memory_deleted_items')}
</h4>
<div className="space-y-2">
{deletedMemories.map((artifact, index) => (
<div key={`delete-${index}`} className="rounded-lg p-3 opacity-60">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-text-secondary">
{artifact.key}
</div>
<div className="text-sm italic text-text-secondary">
{localize('com_ui_memory_deleted')}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -1,8 +1,9 @@
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { ContentTypes } from 'librechat-data-provider';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
import type { Agents } from 'librechat-data-provider';
import type { TEditProps } from '~/common';
import Container from '~/components/Chat/Messages/Content/Container';
import { useChatContext, useAddedChatContext } from '~/Providers';
@ -12,18 +13,19 @@ import { useLocalize } from '~/hooks';
import store from '~/store';
const EditTextPart = ({
text,
part,
index,
messageId,
isSubmitting,
enterEdit,
}: Omit<TEditProps, 'message' | 'ask'> & {
}: Omit<TEditProps, 'message' | 'ask' | 'text'> & {
index: number;
messageId: string;
part: Agents.MessageContentText | Agents.ReasoningDeltaUpdate;
}) => {
const localize = useLocalize();
const { addedIndex } = useAddedChatContext();
const { getMessages, setMessages, conversation } = useChatContext();
const { ask, getMessages, setMessages, conversation } = useChatContext();
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
store.latestMessageFamily(addedIndex),
);
@ -34,15 +36,16 @@ const EditTextPart = ({
[getMessages, messageId],
);
const chatDirection = useRecoilValue(store.chatDirection);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? '');
const chatDirection = useRecoilValue(store.chatDirection).toLowerCase();
const isRTL = chatDirection === 'rtl';
const isRTL = chatDirection?.toLowerCase() === 'rtl';
const { register, handleSubmit, setValue } = useForm({
defaultValues: {
text: text ?? '',
text: (ContentTypes.THINK in part ? part.think : part.text) || '',
},
});
@ -55,15 +58,7 @@ const EditTextPart = ({
}
}, []);
/*
const resubmitMessage = () => {
showToast({
status: 'warning',
message: localize('com_warning_resubmit_unsupported'),
});
// const resubmitMessage = (data: { text: string }) => {
// Not supported by AWS Bedrock
const resubmitMessage = (data: { text: string }) => {
const messages = getMessages();
const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId);
@ -73,17 +68,19 @@ const EditTextPart = ({
ask(
{ ...parentMessage },
{
editedText: data.text,
editedContent: {
index,
text: data.text,
type: part.type,
},
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
},
);
setSiblingIdx((siblingIdx ?? 0) - 1);
enterEdit(true);
};
*/
const updateMessage = (data: { text: string }) => {
const messages = getMessages();
@ -117,9 +114,9 @@ const EditTextPart = ({
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
content: updatedContent,
}
...msg,
content: updatedContent,
}
: msg,
),
);
@ -167,13 +164,13 @@ const EditTextPart = ({
/>
</div>
<div className="mt-2 flex w-full justify-center text-center">
{/* <button
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={handleSubmit(resubmitMessage)}
>
{localize('com_ui_save_submit')}
</button> */}
</button>
<button
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}

View file

@ -32,8 +32,17 @@ export default function OpenAIImageGen({
let height: number | undefined;
let quality: 'low' | 'medium' | 'high' = 'high';
// Parse args if it's a string
let parsedArgs;
try {
const argsObj = typeof _args === 'string' ? JSON.parse(_args) : _args;
parsedArgs = typeof _args === 'string' ? JSON.parse(_args) : _args;
} catch (error) {
console.error('Error parsing args:', error);
parsedArgs = {};
}
try {
const argsObj = parsedArgs;
if (argsObj && typeof argsObj.size === 'string') {
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
@ -169,17 +178,6 @@ export default function OpenAIImageGen({
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
</div>
{/* {showInfo && hasInfo && (
<ToolCallInfo
key="tool-call-info"
input={args ?? ''}
output={output}
function_name={function_name}
pendingAuth={authDomain.length > 0 && !cancelled && initialProgress < 1}
/>
)} */}
<div className="relative mb-2 flex w-full justify-start">
<div ref={containerRef} className="w-full max-w-lg">
{dimensions.width !== 'auto' && progress < 1 && (
@ -197,6 +195,7 @@ export default function OpenAIImageGen({
width={Number(dimensions.width?.split('px')[0])}
height={Number(dimensions.height?.split('px')[0])}
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
args={parsedArgs}
/>
</div>
</div>

View file

@ -59,7 +59,30 @@ export default function ProgressText({
isExpanded?: boolean;
error?: boolean;
}) {
const text = progress < 1 ? (authText ?? inProgressText) : finishedText;
const getText = () => {
if (error) {
return finishedText;
}
if (progress < 1) {
return authText ?? inProgressText;
}
return finishedText;
};
const getIcon = () => {
if (error) {
return <CancelledIcon />;
}
if (progress < 1) {
return <Spinner />;
}
return <FinishedIcon />;
};
const text = getText();
const icon = getIcon();
const showShimmer = progress < 1 && !error;
return (
<Wrapper popover={popover}>
<button
@ -71,13 +94,13 @@ export default function ProgressText({
disabled={!hasInput}
onClick={hasInput ? onClick : undefined}
>
{progress < 1 ? <Spinner /> : error ? <CancelledIcon /> : <FinishedIcon />}
<span className={`${progress < 1 ? 'shimmer' : ''}`}>{text}</span>
{icon}
<span className={showShimmer ? 'shimmer' : ''}>{text}</span>
{hasInput &&
(isExpanded ? (
<ChevronUp className="size-4 translate-y-[1px]" />
<ChevronUp className="size-4 shrink-0 translate-y-[1px]" />
) : (
<ChevronDown className="size-4 translate-y-[1px]" />
<ChevronDown className="size-4 shrink-0 translate-y-[1px]" />
))}
</button>
</Wrapper>

View file

@ -97,7 +97,7 @@ export default function ToolCall({
const getFinishedText = () => {
if (cancelled) {
return localize('com_ui_error');
return localize('com_ui_cancelled');
}
if (isMCPToolCall === true) {
return localize('com_assistants_completed_function', { 0: function_name });
@ -153,7 +153,7 @@ export default function ToolCall({
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<div className="relative my-2.5 flex h-5 shrink-0 items-center gap-2.5">
<ProgressText
progress={progress}
onClick={() => setShowInfo((prev) => !prev)}

View file

@ -0,0 +1,341 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import * as Ariakit from '@ariakit/react';
import { TFeedback, TFeedbackTag, getTagsForRating } from 'librechat-data-provider';
import {
AlertCircle,
PenTool,
ImageOff,
Ban,
HelpCircle,
CheckCircle,
Lightbulb,
Search,
} from 'lucide-react';
import {
Button,
OGDialog,
OGDialogContent,
OGDialogTitle,
ThumbUpIcon,
ThumbDownIcon,
} from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface FeedbackProps {
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
feedback?: TFeedback;
isLast?: boolean;
}
const ICONS = {
AlertCircle,
PenTool,
ImageOff,
Ban,
HelpCircle,
CheckCircle,
Lightbulb,
Search,
ThumbsUp: ThumbUpIcon,
ThumbsDown: ThumbDownIcon,
};
function FeedbackOptionButton({
tag,
active,
onClick,
}: {
tag: TFeedbackTag;
active?: boolean;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const localize = useLocalize();
const Icon = ICONS[tag.icon as keyof typeof ICONS] || AlertCircle;
const label = localize(tag.label as Parameters<typeof localize>[0]);
return (
<button
className={cn(
'flex w-full items-center gap-3 rounded-xl p-2 text-text-secondary transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary',
active && 'bg-surface-hover font-semibold text-text-primary',
)}
onClick={onClick}
type="button"
aria-label={label}
aria-pressed={active}
>
<Icon size="19" bold={active} />
<span>{label}</span>
</button>
);
}
function FeedbackButtons({
isLast,
feedback,
onFeedback,
onOther,
}: {
isLast: boolean;
feedback?: TFeedback;
onFeedback: (fb: TFeedback | undefined) => void;
onOther?: () => void;
}) {
const localize = useLocalize();
const upStore = Ariakit.usePopoverStore({ placement: 'bottom' });
const downStore = Ariakit.usePopoverStore({ placement: 'bottom' });
const positiveTags = useMemo(() => getTagsForRating('thumbsUp'), []);
const negativeTags = useMemo(() => getTagsForRating('thumbsDown'), []);
const upActive = feedback?.rating === 'thumbsUp' ? feedback.tag?.key : undefined;
const downActive = feedback?.rating === 'thumbsDown' ? feedback.tag?.key : undefined;
const handleThumbsUpClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (feedback?.rating !== 'thumbsUp') {
upStore.toggle();
return;
}
onFeedback(undefined);
},
[feedback, onFeedback, upStore],
);
const handleUpOption = useCallback(
(tag: TFeedbackTag) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
upStore.hide();
onFeedback({ rating: 'thumbsUp', tag });
if (tag.key === 'other') {
onOther?.();
}
},
[onFeedback, onOther, upStore],
);
const handleThumbsDownClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (feedback?.rating !== 'thumbsDown') {
downStore.toggle();
return;
}
onOther?.();
},
[feedback, onOther, downStore],
);
const handleDownOption = useCallback(
(tag: TFeedbackTag) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
downStore.hide();
onFeedback({ rating: 'thumbsDown', tag });
if (tag.key === 'other') {
onOther?.();
}
},
[onFeedback, onOther, downStore],
);
return (
<>
<Ariakit.PopoverAnchor
store={upStore}
render={
<button
className={buttonClasses(feedback?.rating === 'thumbsUp', isLast)}
onClick={handleThumbsUpClick}
type="button"
title={localize('com_ui_feedback_positive')}
aria-pressed={feedback?.rating === 'thumbsUp'}
aria-haspopup="menu"
>
<ThumbUpIcon size="19" bold={feedback?.rating === 'thumbsUp'} />
</button>
}
/>
<Ariakit.Popover
store={upStore}
gutter={8}
portal
unmountOnHide
className="popover-animate flex w-auto flex-col gap-1.5 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-1.5 shadow-lg"
>
<div className="flex flex-col items-stretch justify-center">
{positiveTags.map((tag) => (
<FeedbackOptionButton
key={tag.key}
tag={tag}
active={upActive === tag.key}
onClick={handleUpOption(tag)}
/>
))}
</div>
</Ariakit.Popover>
<Ariakit.PopoverAnchor
store={downStore}
render={
<button
className={buttonClasses(feedback?.rating === 'thumbsDown', isLast)}
onClick={handleThumbsDownClick}
type="button"
title={localize('com_ui_feedback_negative')}
aria-pressed={feedback?.rating === 'thumbsDown'}
aria-haspopup="menu"
>
<ThumbDownIcon size="19" bold={feedback?.rating === 'thumbsDown'} />
</button>
}
/>
<Ariakit.Popover
store={downStore}
gutter={8}
portal
unmountOnHide
className="popover-animate flex w-auto flex-col gap-1.5 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-1.5 shadow-lg"
>
<div className="flex flex-col items-stretch justify-center">
{negativeTags.map((tag) => (
<FeedbackOptionButton
key={tag.key}
tag={tag}
active={downActive === tag.key}
onClick={handleDownOption(tag)}
/>
))}
</div>
</Ariakit.Popover>
</>
);
}
function buttonClasses(isActive: boolean, isLast: boolean) {
return cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'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',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
isActive && 'active text-text-primary bg-surface-hover',
);
}
export default function Feedback({
isLast = false,
handleFeedback,
feedback: initialFeedback,
}: FeedbackProps) {
const localize = useLocalize();
const [openDialog, setOpenDialog] = useState(false);
const [feedback, setFeedback] = useState<TFeedback | undefined>(initialFeedback);
useEffect(() => {
setFeedback(initialFeedback);
}, [initialFeedback]);
const propagateMinimal = useCallback(
(fb: TFeedback | undefined) => {
setFeedback(fb);
handleFeedback({ feedback: fb });
},
[handleFeedback],
);
const handleButtonFeedback = useCallback(
(fb: TFeedback | undefined) => {
if (fb?.tag?.key === 'other') setOpenDialog(true);
else setOpenDialog(false);
propagateMinimal(fb);
},
[propagateMinimal],
);
const handleOtherOpen = useCallback(() => setOpenDialog(true), []);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFeedback((prev) => (prev ? { ...prev, text: e.target.value } : undefined));
};
const handleDialogSave = useCallback(() => {
if (feedback?.tag?.key === 'other' && !feedback?.text?.trim()) {
return;
}
propagateMinimal(feedback);
setOpenDialog(false);
}, [feedback, propagateMinimal]);
const handleDialogClear = useCallback(() => {
setFeedback(undefined);
handleFeedback({ feedback: undefined });
setOpenDialog(false);
}, [handleFeedback]);
const renderSingleFeedbackButton = () => {
if (!feedback) return null;
const isThumbsUp = feedback.rating === 'thumbsUp';
const Icon = isThumbsUp ? ThumbUpIcon : ThumbDownIcon;
const label = isThumbsUp
? localize('com_ui_feedback_positive')
: localize('com_ui_feedback_negative');
return (
<button
className={buttonClasses(true, isLast)}
onClick={() => {
if (isThumbsUp) {
handleButtonFeedback(undefined);
} else {
setOpenDialog(true);
}
}}
type="button"
title={label}
aria-pressed="true"
>
<Icon size="19" bold />
</button>
);
};
return (
<>
{feedback ? (
renderSingleFeedbackButton()
) : (
<FeedbackButtons
isLast={isLast}
feedback={feedback}
onFeedback={handleButtonFeedback}
onOther={handleOtherOpen}
/>
)}
<OGDialog open={openDialog} onOpenChange={setOpenDialog}>
<OGDialogContent className="w-11/12 max-w-lg">
<OGDialogTitle className="text-token-text-primary text-lg font-semibold leading-6">
{localize('com_ui_feedback_more_information')}
</OGDialogTitle>
<textarea
className="w-full rounded-xl border border-border-light bg-transparent p-2 text-text-primary"
value={feedback?.text || ''}
onChange={handleTextChange}
rows={4}
placeholder={localize('com_ui_feedback_placeholder')}
maxLength={500}
/>
<div className="mt-4 flex items-end justify-end gap-2">
<Button variant="destructive" onClick={handleDialogClear}>
{localize('com_ui_delete')}
</Button>
<Button variant="submit" onClick={handleDialogSave} disabled={!feedback?.text?.trim()}>
{localize('com_ui_save')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
</>
);
}

View file

@ -0,0 +1,422 @@
import React, { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { GitFork, InfoIcon } from 'lucide-react';
import { ForkOptions } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
interface PopoverButtonProps {
children: React.ReactNode;
setting: ForkOptions;
onClick: (setting: ForkOptions) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode | string;
hoverTitle?: React.ReactNode | string;
hoverDescription?: React.ReactNode | string;
label: string;
}
const optionLabels: Record<ForkOptions, TranslationKeys> = {
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
};
const chevronDown = (
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
);
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
label,
}) => {
const localize = useLocalize();
return (
<Ariakit.HovercardProvider placement="right-start">
<div className="flex flex-col items-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-0.5 w-14 flex-1 rounded-xl border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-200 ease-in-out hover:bg-surface-hover hover:text-text-primary"
aria-label={label}
>
{children}
<VisuallyHidden>{label}</VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_details_about', { 0: label })}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<Ariakit.Hovercard
gutter={16}
shift={40}
flip={false}
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-text-secondary">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</Ariakit.Hovercard>
)}
</div>
</Ariakit.HovercardProvider>
);
};
interface CheckboxOptionProps {
id: string;
checked: boolean;
onToggle: (checked: boolean) => void;
labelKey: TranslationKeys;
infoKey: TranslationKeys;
showToastOnCheck?: boolean;
}
const CheckboxOption: React.FC<CheckboxOptionProps> = ({
id,
checked,
onToggle,
labelKey,
infoKey,
showToastOnCheck = false,
}) => {
const localize = useLocalize();
const { showToast } = useToastContext();
return (
<Ariakit.HovercardProvider placement="right-start">
<div className="flex items-center">
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
<Ariakit.HovercardAnchor
render={
<div>
<Ariakit.Checkbox
id={id}
checked={checked}
onChange={(e) => {
const value = e.target.checked;
if (value && showToastOnCheck) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
onToggle(value);
}}
className="h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize(labelKey)}
/>
<label htmlFor={id} className="ml-2 cursor-pointer">
{localize(labelKey)}
</label>
</div>
}
/>
</div>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize(infoKey)}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize(infoKey)}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
);
};
export default function Fork({
messageId,
conversationId: _convoId,
forkingSupported = false,
latestMessageId,
isLast = false,
}: {
messageId: string;
conversationId: string | null;
forkingSupported?: boolean;
latestMessageId?: string;
isLast?: boolean;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo();
const [isActive, setIsActive] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
const popoverStore = Ariakit.usePopoverStore({
placement: 'bottom',
});
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'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',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
isActive && 'active text-text-primary bg-surface-hover',
);
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_fork_success'),
status: 'success',
});
},
onMutate: () => {
showToast({
message: localize('com_ui_fork_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_fork_error'),
status: 'error',
});
},
});
const conversationId = _convoId ?? '';
if (!forkingSupported || !conversationId || !messageId) {
return null;
}
const onClick = (option: string) => {
if (remember) {
setRememberGlobal(true);
setForkSetting(option);
}
forkConvo.mutate({
messageId,
conversationId,
option,
splitAtTarget,
latestMessageId,
});
};
const forkOptionsConfig = [
{
setting: ForkOptions.DIRECT_PATH,
label: localize(optionLabels[ForkOptions.DIRECT_PATH]),
icon: <GitCommit className="h-full w-full rotate-90 p-2" />,
hoverTitle: (
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
),
hoverDescription: localize('com_ui_fork_info_visible'),
},
{
setting: ForkOptions.INCLUDE_BRANCHES,
label: localize(optionLabels[ForkOptions.INCLUDE_BRANCHES]),
icon: <GitBranchPlus className="h-full w-full rotate-180 p-2" />,
hoverTitle: (
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
),
hoverDescription: localize('com_ui_fork_info_branches'),
},
{
setting: ForkOptions.TARGET_LEVEL,
label: localize(optionLabels[ForkOptions.TARGET_LEVEL]),
icon: <ListTree className="h-full w-full p-2" />,
hoverTitle: (
<>
<ListTree className="h-5 w-5" />
{`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize('com_endpoint_default')})`}
</>
),
hoverDescription: localize('com_ui_fork_info_target'),
},
];
return (
<>
<Ariakit.PopoverAnchor
store={popoverStore}
render={
<button
className={buttonStyle}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
} else {
popoverStore.toggle();
setIsActive(popoverStore.getState().open);
}
}}
type="button"
aria-label={localize('com_ui_fork')}
>
<GitFork size="19" />
</button>
}
/>
<Ariakit.Popover
store={popoverStore}
gutter={10}
className={`popover-animate ${isActive ? 'open' : ''} flex w-60 flex-col gap-3 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-2 px-4 shadow-lg`}
style={{
outline: 'none',
pointerEvents: 'auto',
zIndex: 50,
}}
portal={true}
unmountOnHide={true}
onClose={() => setIsActive(false)}
>
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
{localize(activeSetting)}
<Ariakit.HovercardProvider placement="right-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
<Ariakit.HovercardAnchor
render={
<button
className="flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary"
aria-label={localize('com_ui_fork_info_button_label')}
>
<InfoIcon />
</button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={19}
shift={40}
flip={false}
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
{forkOptionsConfig.map((opt) => (
<PopoverButton
key={opt.setting}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={opt.setting}
label={opt.label}
hoverTitle={opt.hoverTitle}
hoverDescription={opt.hoverDescription}
>
{opt.icon}
</PopoverButton>
))}
</div>
<CheckboxOption
id="split-target-checkbox"
checked={splitAtTarget}
onToggle={setSplitAtTarget}
labelKey="com_ui_fork_split_target"
infoKey="com_ui_fork_info_start"
/>
<CheckboxOption
id="remember-checkbox"
checked={remember}
onToggle={(checked) => {
if (checked)
showToast({ message: localize('com_ui_fork_remember_checked'), status: 'info' });
setRemember(checked);
}}
labelKey="com_ui_fork_remember"
infoKey="com_ui_fork_info_remember"
showToastOnCheck
/>
</Ariakit.Popover>
</>
);
}

View file

@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useMemo, memo } from 'react';
import { useRecoilState } from 'recoil';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '~/components/svg';
import type { TConversation, TMessage, TFeedback } from 'librechat-data-provider';
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '~/components';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
import { Fork } from '~/components/Conversations';
import MessageAudio from './MessageAudio';
import Feedback from './Feedback';
import { cn } from '~/utils';
import store from '~/store';
@ -20,9 +21,92 @@ type THoverButtons = {
latestMessage: TMessage | null;
isLast: boolean;
index: number;
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
};
export default function HoverButtons({
type HoverButtonProps = {
id?: string;
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
title: string;
icon: React.ReactNode;
isActive?: boolean;
isVisible?: boolean;
isDisabled?: boolean;
isLast?: boolean;
className?: string;
buttonStyle?: string;
};
const extractMessageContent = (message: TMessage): string => {
if (typeof message.content === 'string') {
return message.content;
}
if (Array.isArray(message.content)) {
return message.content
.map((part) => {
if (typeof part === 'string') {
return part;
}
if ('text' in part) {
return part.text || '';
}
if ('think' in part) {
const think = part.think;
if (typeof think === 'string') {
return think;
}
return think && 'text' in think ? think.text || '' : '';
}
return '';
})
.join('');
}
return message.text || '';
};
const HoverButton = memo(
({
id,
onClick,
title,
icon,
isActive = false,
isVisible = true,
isDisabled = false,
isLast = false,
className = '',
}: HoverButtonProps) => {
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'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',
!isVisible && 'opacity-0',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
isActive && isVisible && 'active text-text-primary bg-surface-hover',
className,
);
return (
<button
id={id}
className={buttonStyle}
onClick={onClick}
type="button"
title={title}
disabled={isDisabled}
>
{icon}
</button>
);
},
);
HoverButton.displayName = 'HoverButton';
const HoverButtons = ({
index,
isEditing,
enterEdit,
@ -34,20 +118,20 @@ export default function HoverButtons({
handleContinue,
latestMessage,
isLast,
}: THoverButtons) {
handleFeedback,
}: THoverButtons) => {
const localize = useLocalize();
const { endpoint: _endpoint, endpointType } = conversation ?? {};
const endpoint = endpointType ?? _endpoint;
const [isCopied, setIsCopied] = useState(false);
const [TextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const {
hideEditButton,
regenerateEnabled,
continueSupported,
forkingSupported,
isEditableEndpoint,
} = useGenerationsByLatest({
const endpoint = useMemo(() => {
if (!conversation) {
return '';
}
return conversation.endpointType ?? conversation.endpoint;
}, [conversation]);
const generationCapabilities = useGenerationsByLatest({
isEditing,
isSubmitting,
error: message.error,
@ -58,38 +142,32 @@ export default function HoverButtons({
isCreatedByUser: message.isCreatedByUser,
latestMessageId: latestMessage?.messageId,
});
const {
hideEditButton,
regenerateEnabled,
continueSupported,
forkingSupported,
isEditableEndpoint,
} = generationCapabilities;
if (!conversation) {
return null;
}
const { isCreatedByUser, error } = message;
const renderRegenerate = () => {
if (!regenerateEnabled) {
return null;
}
return (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={regenerate}
type="button"
title={localize('com_ui_regenerate')}
>
<RegenerateIcon
className="hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
size="19"
/>
</button>
);
};
if (error === true) {
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
{renderRegenerate()}
<div className="visible flex justify-center self-end lg:justify-start">
{regenerateEnabled && (
<HoverButton
onClick={regenerate}
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
/>
)}
</div>
);
}
@ -101,72 +179,92 @@ export default function HoverButtons({
enterEdit();
};
const handleCopy = () => copyToClipboard(setIsCopied);
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
<div className="group visible flex justify-center gap-0.5 self-end focus-within:outline-none lg:justify-start">
{/* Text to Speech */}
{TextToSpeech && (
<MessageAudio
index={index}
messageId={message.messageId}
content={message.content ?? message.text}
isLast={isLast}
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
messageId={message.messageId}
content={extractMessageContent(message)}
renderButton={(props) => (
<HoverButton
onClick={props.onClick}
title={props.title}
icon={props.icon}
isActive={props.isActive}
isLast={isLast}
/>
)}
/>
)}
{isEditableEndpoint && (
<button
id={`edit-${message.messageId}`}
className={cn(
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active text-gray-700 dark:text-gray-200' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={onEdit}
type="button"
title={localize('com_ui_edit')}
disabled={hideEditButton}
>
<EditIcon size="19" />
</button>
)}
<button
className={cn(
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={() => copyToClipboard(setIsCopied)}
type="button"
{/* Copy Button */}
<HoverButton
onClick={handleCopy}
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
</button>
{renderRegenerate()}
<Fork
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' : ''}`}
/>
{/* Edit Button */}
{isEditableEndpoint && (
<HoverButton
id={`edit-${message.messageId}`}
onClick={onEdit}
title={localize('com_ui_edit')}
icon={<EditIcon size="19" />}
isActive={isEditing}
isVisible={!hideEditButton}
isDisabled={hideEditButton}
isLast={isLast}
className={isCreatedByUser ? '' : 'active'}
/>
)}
{/* Fork Button */}
<Fork
messageId={message.messageId}
conversationId={conversation.conversationId}
forkingSupported={forkingSupported}
latestMessageId={latestMessage?.messageId}
isLast={isLast}
/>
{continueSupported === true ? (
<button
className={cn(
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={handleContinue}
type="button"
{/* Feedback Buttons */}
{!isCreatedByUser && (
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
)}
{/* Regenerate Button */}
{regenerateEnabled && (
<HoverButton
onClick={regenerate}
title={localize('com_ui_regenerate')}
icon={<RegenerateIcon size="19" />}
isLast={isLast}
className="active"
/>
)}
{/* Continue Button */}
{continueSupported && (
<HoverButton
onClick={(e) => e && handleContinue(e)}
title={localize('com_ui_continue')}
>
<ContinueIcon className="h-4 w-4 hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
) : null}
icon={<ContinueIcon className="w-19 h-19 -rotate-180" />}
isLast={isLast}
className="active"
/>
)}
</div>
);
}
};
export default memo(HoverButtons);

View file

@ -14,6 +14,9 @@ function MessageAudio(props: TMessageAudio) {
};
const SelectedTTS = TTSComponents[engineTTS];
if (!SelectedTTS) {
return null;
}
return <SelectedTTS {...props} />;
}

View file

@ -1,3 +1,4 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { TMessageProps } from '~/common';
import { cn } from '~/utils';
@ -22,57 +23,46 @@ export default function SiblingSwitch({
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
};
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'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',
);
return siblingCount > 1 ? (
<div className="visible flex items-center justify-center gap-1 self-center pt-0 text-xs">
<nav
className="visible flex items-center justify-center gap-2 self-center pt-0 text-xs"
aria-label="Sibling message navigation"
>
<button
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
)}
className={buttonStyle}
type="button"
onClick={previous}
disabled={siblingIdx == 0}
aria-label="Previous sibling message"
aria-disabled={siblingIdx == 0}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="15 18 9 12 15 6" />
</svg>
<ChevronLeft size="19" aria-hidden="true" />
</button>
<span className="flex-shrink-0 flex-grow tabular-nums">
<span
className="flex-shrink-0 flex-grow tabular-nums"
aria-live="polite"
aria-atomic="true"
role="status"
>
{siblingIdx + 1} / {siblingCount}
</span>
<button
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
)}
className={buttonStyle}
type="button"
onClick={next}
disabled={siblingIdx == siblingCount - 1}
aria-label="Next sibling message"
aria-disabled={siblingIdx == siblingCount - 1}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<ChevronRight size="19" aria-hidden="true" />
</button>
</div>
</nav>
) : null;
}

View file

@ -1,6 +1,6 @@
import React, { useCallback, useMemo, memo } from 'react';
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo, memo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { type TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
@ -51,13 +51,13 @@ const MessageRender = memo(
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
} = useMessageActions({
message: msg,
currentEditId,
isMultiMessage,
setCurrentEditId,
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
@ -206,6 +206,7 @@ const MessageRender = memo(
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>

View file

@ -42,7 +42,7 @@ export function TemporaryChat() {
render={
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
aria-label={localize(temporaryBadge.label)}
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',
isTemporary

View file

@ -1,408 +0,0 @@
import React, { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { GitFork, InfoIcon } from 'lucide-react';
import { ForkOptions } from 'librechat-data-provider';
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { cn } from '~/utils';
import store from '~/store';
interface PopoverButtonProps {
children: React.ReactNode;
setting: ForkOptions;
onClick: (setting: ForkOptions) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode | string;
hoverTitle?: React.ReactNode | string;
hoverDescription?: React.ReactNode | string;
label: string;
}
const optionLabels: Record<ForkOptions, TranslationKeys> = {
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
};
const chevronDown = (
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
);
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
label,
}) => {
const localize = useLocalize();
return (
<Ariakit.HovercardProvider>
<div className="flex flex-col items-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-300 ease-in-out hover:border-border-xheavy hover:bg-surface-hover hover:text-text-primary"
aria-label={label}
>
{children}
<VisuallyHidden>{label}</VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_details_about', { 0: label })}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<Ariakit.Hovercard
gutter={16}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-text-secondary">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</Ariakit.Hovercard>
)}
</div>
</Ariakit.HovercardProvider>
);
};
export default function Fork({
isLast = false,
messageId,
conversationId: _convoId,
forkingSupported = false,
latestMessageId,
}: {
isLast?: boolean;
messageId: string;
conversationId: string | null;
forkingSupported?: boolean;
latestMessageId?: string;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
const popoverStore = Ariakit.usePopoverStore({
placement: 'top',
});
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
showToast({
message: localize('com_ui_fork_success'),
status: 'success',
});
},
onMutate: () => {
showToast({
message: localize('com_ui_fork_processing'),
status: 'info',
});
},
onError: () => {
showToast({
message: localize('com_ui_fork_error'),
status: 'error',
});
},
});
const conversationId = _convoId ?? '';
if (!forkingSupported || !conversationId || !messageId) {
return null;
}
const onClick = (option: string) => {
if (remember) {
setRememberGlobal(true);
setForkSetting(option);
}
forkConvo.mutate({
messageId,
conversationId,
option,
splitAtTarget,
latestMessageId,
});
};
return (
<>
<Ariakit.PopoverAnchor
store={popoverStore}
render={
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast
? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100'
: '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
} else {
popoverStore.toggle();
}
}}
type="button"
aria-label={localize('com_ui_fork')}
>
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
}
/>
<Ariakit.Popover
store={popoverStore}
gutter={5}
className="flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg border border-border-heavy bg-surface-secondary p-2 px-3 shadow-lg"
style={{
outline: 'none',
pointerEvents: 'auto',
zIndex: 50,
}}
portal={true}
unmountOnHide={true}
>
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
{localize(activeSetting)}
<Ariakit.HovercardProvider>
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
<Ariakit.HovercardAnchor
render={
<button
className="flex h-5 w-5 items-center rounded-full text-text-secondary"
aria-label={localize('com_ui_fork_info_button_label')}
>
<InfoIcon />
</button>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={19}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
label={localize(optionLabels[ForkOptions.DIRECT_PATH])}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<GitCommit className="h-full w-full rotate-90 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
label={localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
label={localize(optionLabels[ForkOptions.TARGET_LEVEL])}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(
optionLabels[ForkOptions.TARGET_LEVEL],
)} (${localize('com_endpoint_default')})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<ListTree className="h-full w-full p-2" />
</PopoverButton>
</div>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
<Ariakit.Checkbox
id="split-target-checkbox"
checked={splitAtTarget}
onChange={(event) => setSplitAtTarget(event.target.checked)}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_split_target')}
/>
<label htmlFor="split-target-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_split_target')}
</label>
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_split_target', {
0: localize('com_ui_fork_split_target'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={32}
className="z-[999] w-80 select-none rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_start')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div
onClick={() => setRemember((prev) => !prev)}
className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"
>
<Ariakit.Checkbox
id="remember-checkbox"
checked={remember}
onChange={(event) => {
const checked = event.target.checked;
console.log('checked', checked);
if (checked) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
return setRemember(checked);
}}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_remember')}
/>
<label htmlFor="remember-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_remember')}
</label>
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_remember', {
0: localize('com_ui_fork_remember'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={14}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_remember')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</Ariakit.Popover>
</>
);
}

View file

@ -1,4 +1,4 @@
export { default as Fork } from './Fork';
export { default as Fork } from '../Chat/Messages/Fork';
export { default as Pages } from './Pages';
export { default as Conversations } from './Conversations';
export * from './ConvoOptions';

View file

@ -1,15 +0,0 @@
import type { TGenButtonProps } from '~/common';
import { ContinueIcon } from '~/components/svg';
import Button from './Button';
import { useLocalize } from '~/hooks';
export default function Continue({ onClick }: TGenButtonProps) {
const localize = useLocalize();
return (
<Button type="continue" onClick={onClick}>
<ContinueIcon className="text-gray-600/90 dark:text-gray-400 " />
{localize('com_ui_continue')}
</Button>
);
}

View file

@ -1,22 +0,0 @@
import { render, fireEvent } from 'test/layout-test-utils';
import Continue from '../Continue';
describe('Continue', () => {
it('should render the Continue button', () => {
const { getByText } = render(
<Continue
onClick={() => {
('');
}}
/>,
);
expect(getByText('Continue')).toBeInTheDocument();
});
it('should call onClick when the button is clicked', () => {
const handleClick = jest.fn();
const { getByText } = render(<Continue onClick={handleClick} />);
fireEvent.click(getByText('Continue'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -62,6 +62,7 @@ const errorMessages = {
const { info } = json;
return info;
},
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
[ViolationTypes.BAN]:
'Your account has been temporarily banned due to violations of our service.',
invalid_api_key:

View file

@ -52,6 +52,7 @@ const ContentRender = memo(
copyToClipboard,
setLatestMessage,
regenerateMessage,
handleFeedback,
} = useMessageActions({
message: msg,
searchResults,
@ -59,7 +60,6 @@ const ContentRender = memo(
isMultiMessage,
setCurrentEditId,
});
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
@ -199,6 +199,7 @@ const ContentRender = memo(
copyToClipboard={copyToClipboard}
handleContinue={handleContinue}
latestMessage={latestMessage}
handleFeedback={handleFeedback}
isLast={isLast}
/>
</SubRow>

View file

@ -78,7 +78,8 @@ function AccountSettings() {
{startupConfig?.balance?.enabled === true && balanceQuery.data != null && (
<>
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
{localize('com_nav_balance')}: {balanceQuery.data.tokenCredits.toFixed(2)}
{localize('com_nav_balance')}:{' '}
{new Intl.NumberFormat().format(Math.round(balanceQuery.data.tokenCredits))}
</div>
<DropdownMenuSeparator />
</>

View file

@ -30,7 +30,7 @@ const NavMask = memo(
id="mobile-nav-mask-toggle"
role="button"
tabIndex={0}
className={`nav-mask ${navVisible ? 'active' : ''}`}
className={`nav-mask transition-opacity duration-200 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`}
onClick={toggleNavVisible}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@ -186,18 +186,19 @@ const Nav = memo(
<div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'nav active max-w-[320px] flex-shrink-0 transform overflow-x-hidden bg-surface-primary-alt transition-all duration-200 ease-in-out',
'md:max-w-[260px]',
)}
style={{
width: navVisible ? navWidth : '0px',
visibility: navVisible ? 'visible' : 'hidden',
transition: 'width 0.2s, visibility 0.2s',
transform: navVisible ? 'translateX(0)' : 'translateX(-100%)',
}}
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<div className="flex h-full flex-col transition-opacity">
<div
className={`flex h-full flex-col transition-opacity duration-200 ease-in-out ${navVisible ? 'opacity-100' : 'opacity-0'}`}
>
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"

View file

@ -103,7 +103,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
ref={ref}
className={cn(
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
isSmallScreen === true ? 'mb-2 h-14 rounded-xl' : '',
)}
>
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />

View file

@ -5,9 +5,27 @@ import { SettingsTabValues } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider';
import type { TDialogProps } from '~/common';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg';
import { General, Chat, Speech, Beta, Commands, Data, Account, Balance } from './SettingsTabs';
import {
GearIcon,
DataIcon,
SpeechIcon,
UserIcon,
ExperimentIcon,
PersonalizationIcon,
} from '~/components/svg';
import {
General,
Chat,
Speech,
Beta,
Commands,
Data,
Account,
Balance,
Personalization,
} from './SettingsTabs';
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
import { cn } from '~/utils';
export default function Settings({ open, onOpenChange }: TDialogProps) {
@ -16,6 +34,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
const localize = useLocalize();
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
const tabRefs = useRef({});
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
const handleKeyDown = (event: React.KeyboardEvent) => {
const tabs: SettingsTabValues[] = [
@ -24,6 +43,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
SettingsTabValues.BETA,
SettingsTabValues.COMMANDS,
SettingsTabValues.SPEECH,
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
SettingsTabValues.DATA,
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
SettingsTabValues.ACCOUNT,
@ -80,6 +100,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
icon: <SpeechIcon className="icon-sm" />,
label: 'com_nav_setting_speech',
},
...(hasAnyPersonalizationFeature
? [
{
value: SettingsTabValues.PERSONALIZATION,
icon: <PersonalizationIcon />,
label: 'com_nav_setting_personalization' as TranslationKeys,
},
]
: []),
{
value: SettingsTabValues.DATA,
icon: <DataIcon />,
@ -87,11 +116,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
},
...(startupConfig?.balance?.enabled
? [
{
value: SettingsTabValues.BALANCE,
{
value: SettingsTabValues.BALANCE,
icon: <DollarSign size={18} />,
label: 'com_nav_setting_balance' as TranslationKeys,
},
label: 'com_nav_setting_balance' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
{
@ -213,6 +242,14 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Content value={SettingsTabValues.SPEECH}>
<Speech />
</Tabs.Content>
{hasAnyPersonalizationFeature && (
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
<Personalization
hasMemoryOptOut={hasMemoryOptOut}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/>
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.DATA}>
<Data />
</Tabs.Content>

View file

@ -15,7 +15,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits }) =>
{/* Left Section: Label */}
<div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_balance')}</Label>
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
<HoverCardSettings side="bottom" text="com_nav_info_balance" />
</div>
{/* Right Section: tokenCredits Value */}

View file

@ -1,34 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function AutoScrollSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [autoScroll, setAutoScroll] = useRecoilState<boolean>(store.autoScroll);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAutoScroll(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div> {localize('com_nav_auto_scroll')} </div>
<Switch
id="autoScroll"
checked={autoScroll}
aria-label="Auto-Scroll to latest message on chat open"
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="autoScroll"
/>
</div>
);
}

View file

@ -1,10 +1,7 @@
import { useRecoilState } from 'recoil';
import Cookies from 'js-cookie';
import React, { useContext, useCallback } from 'react';
import UserMsgMarkdownSwitch from './UserMsgMarkdownSwitch';
import HideSidePanelSwitch from './HideSidePanelSwitch';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { ThemeContext, useLocalize } from '~/hooks';
import AutoScrollSwitch from './AutoScrollSwitch';
import ArchivedChats from './ArchivedChats';
import ToggleSwitch from '../ToggleSwitch';
import { Dropdown } from '~/components';
@ -89,6 +86,7 @@ export const LangSelector = ({
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
{ value: 'he-HE', label: localize('com_nav_lang_hebrew') },
{ value: 'hu-HU', label: localize('com_nav_lang_hungarian') },
{ value: 'hy-AM', label: localize('com_nav_lang_armenian') },
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
@ -99,9 +97,11 @@ export const LangSelector = ({
{ value: 'cs-CZ', label: localize('com_nav_lang_czech') },
{ value: 'sv-SE', label: localize('com_nav_lang_swedish') },
{ value: 'ko-KR', label: localize('com_nav_lang_korean') },
{ value: 'lv-LV', label: localize('com_nav_lang_latvian') },
{ value: 'vi-VN', label: localize('com_nav_lang_vietnamese') },
{ value: 'th-TH', label: localize('com_nav_lang_thai') },
{ value: 'tr-TR', label: localize('com_nav_lang_turkish') },
{ value: 'ug', label: localize('com_nav_lang_uyghur') },
{ value: 'nl-NL', label: localize('com_nav_lang_dutch') },
{ value: 'id-ID', label: localize('com_nav_lang_indonesia') },
{ value: 'fi-FI', label: localize('com_nav_lang_finnish') },

View file

@ -1,35 +0,0 @@
import { useRecoilState } from 'recoil';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function HideSidePanelSwitch({
onCheckedChange,
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const [hideSidePanel, setHideSidePanel] = useRecoilState<boolean>(store.hideSidePanel);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setHideSidePanel(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_hide_panel')}</div>
<Switch
id="hideSidePanel"
checked={hideSidePanel}
aria-label="Hide right-most side panel"
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="hideSidePanel"
/>
</div>
);
}

View file

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

View file

@ -0,0 +1,87 @@
import { useState, useEffect } from 'react';
import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
interface PersonalizationProps {
hasMemoryOptOut: boolean;
hasAnyPersonalizationFeature: boolean;
}
export default function Personalization({
hasMemoryOptOut,
hasAnyPersonalizationFeature,
}: PersonalizationProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: user } = useGetUserQuery();
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_preferences_updated'),
status: 'success',
});
},
onError: () => {
showToast({
message: localize('com_ui_error_updating_preferences'),
status: 'error',
});
// Revert the toggle on error
setReferenceSavedMemories((prev) => !prev);
},
});
// Initialize state from user data
useEffect(() => {
if (user?.personalization?.memories !== undefined) {
setReferenceSavedMemories(user.personalization.memories);
}
}, [user?.personalization?.memories]);
const handleMemoryToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
updateMemoryPreferencesMutation.mutate({ memories: checked });
};
if (!hasAnyPersonalizationFeature) {
return (
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="text-text-secondary">{localize('com_ui_no_personalization_available')}</div>
</div>
);
}
return (
<div className="flex flex-col gap-3 text-sm text-text-primary">
{/* Memory Settings Section */}
{hasMemoryOptOut && (
<>
<div className="border-b border-border-medium pb-3">
<div className="text-base font-semibold">{localize('com_ui_memory')}</div>
</div>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
{localize('com_ui_reference_saved_memories')}
</div>
<div className="mt-1 text-xs text-text-secondary">
{localize('com_ui_reference_saved_memories_description')}
</div>
</div>
<Switch
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
disabled={updateMemoryPreferencesMutation.isLoading}
aria-label={localize('com_ui_reference_saved_memories')}
/>
</div>
</>
)}
</div>
);
}

View file

@ -14,9 +14,9 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
const endpointOptions = external
? [
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
: [{ value: 'browser', label: localize('com_nav_browser') }];
const handleSelect = (value: string) => {

View file

@ -76,16 +76,10 @@ function Speech() {
playbackRate: { value: playbackRate, setFunc: setPlaybackRate },
};
if (
(settings[key].value !== newValue || settings[key].value === newValue || !settings[key]) &&
settings[key].value === 'sttExternal' &&
settings[key].value === 'ttsExternal'
) {
return;
}
const setting = settings[key];
setting.setFunc(newValue);
if (setting) {
setting.setFunc(newValue);
}
},
[
sttExternal,
@ -130,13 +124,20 @@ function Speech() {
useEffect(() => {
if (data && data.message !== 'not_found') {
Object.entries(data).forEach(([key, value]) => {
updateSetting(key, value);
// Only apply config values as defaults if no user preference exists in localStorage
const existingValue = localStorage.getItem(key);
if (existingValue === null && key !== 'sttExternal' && key !== 'ttsExternal') {
updateSetting(key, value);
} else if (key === 'sttExternal' || key === 'ttsExternal') {
updateSetting(key, value);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Reset engineTTS if it is set to a removed/invalid value (e.g., 'edge')
// TODO: remove this once the 'edge' engine is fully deprecated
useEffect(() => {
const validEngines = ['browser', 'external'];
if (!validEngines.includes(engineTTS)) {

View file

@ -14,9 +14,9 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
const endpointOptions = external
? [
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
{ value: 'browser', label: localize('com_nav_browser') },
{ value: 'external', label: localize('com_nav_external') },
]
: [{ value: 'browser', label: localize('com_nav_browser') }];
const handleSelect = (value: string) => {

View file

@ -7,3 +7,4 @@ export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';
export { default as Balance } from './Balance/Balance';
export { default as Speech } from './Speech/Speech';
export { default as Personalization } from './Personalization';

View file

@ -0,0 +1,72 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocalize } from '~/hooks';
export default function OAuthError() {
const localize = useLocalize();
const [searchParams] = useSearchParams();
const error = searchParams.get('error') || 'unknown_error';
const getErrorMessage = (error: string): string => {
switch (error) {
case 'missing_code':
return (
localize('com_ui_oauth_error_missing_code') ||
'Authorization code is missing. Please try again.'
);
case 'missing_state':
return (
localize('com_ui_oauth_error_missing_state') ||
'State parameter is missing. Please try again.'
);
case 'invalid_state':
return (
localize('com_ui_oauth_error_invalid_state') ||
'Invalid state parameter. Please try again.'
);
case 'callback_failed':
return (
localize('com_ui_oauth_error_callback_failed') ||
'Authentication callback failed. Please try again.'
);
default:
return localize('com_ui_oauth_error_generic') || error.replace(/_/g, ' ');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<div className="mb-4 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg
className="h-6 w-6 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
<h1 className="mb-4 text-3xl font-bold text-gray-900">
{localize('com_ui_oauth_error_title') || 'Authentication Failed'}
</h1>
<p className="mb-6 text-sm text-gray-600">{getErrorMessage(error)}</p>
<button
onClick={() => window.close()}
className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
aria-label={localize('com_ui_close_window') || 'Close Window'}
>
{localize('com_ui_close_window') || 'Close Window'}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocalize } from '~/hooks';
export default function OAuthSuccess() {
const localize = useLocalize();
const [searchParams] = useSearchParams();
const [secondsLeft, setSecondsLeft] = useState(3);
const serverName = searchParams.get('serverName');
useEffect(() => {
const countdown = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
clearInterval(countdown);
window.close();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(countdown);
}, []);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-3xl font-bold text-gray-900">
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
</h1>
<p className="mb-2 text-sm text-gray-600">
{localize('com_ui_oauth_success_description') ||
'Your authentication was successful. This window will close in'}{' '}
<span className="font-medium text-indigo-500">{secondsLeft}</span>{' '}
{localize('com_ui_seconds') || 'seconds'}.
</p>
{serverName && (
<p className="mt-4 text-xs text-gray-500">
{localize('com_ui_oauth_connected_to') || 'Connected to'}:{' '}
<span className="font-medium">{serverName}</span>
</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
export { default as OAuthSuccess } from './OAuthSuccess';
export { default as OAuthError } from './OAuthError';

View file

@ -81,7 +81,7 @@ const AdminSettings = () => {
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]?.permissions) {
return roles[selectedRole].permissions[PermissionTypes.PROMPTS];
return roles[selectedRole]?.permissions[PermissionTypes.PROMPTS];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS];
}, [roles, selectedRole]);
@ -99,11 +99,7 @@ const AdminSettings = () => {
});
useEffect(() => {
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]) {
reset(roles[selectedRole].permissions[PermissionTypes.PROMPTS]);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS]);
}
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]);
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {

View file

@ -44,11 +44,11 @@ export default function FilterPrompts({
const categoryOptions = categories
? [...categories]
: [
{
value: SystemCategories.NO_CATEGORY,
label: localize('com_ui_no_category'),
},
];
{
value: SystemCategories.NO_CATEGORY,
label: localize('com_ui_no_category'),
},
];
return [...baseOptions, ...categoryOptions];
}, [categories, localize]);

View file

@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
import ManagePrompts from '~/components/Prompts/ManagePrompts';
import { useMediaQuery, usePromptGroupsNav } from '~/hooks';
import List from '~/components/Prompts/Groups/List';
import { cn } from '~/utils';
@ -38,14 +39,17 @@ export default function GroupSidePanel({
<div className="flex-grow overflow-y-auto">
<List groups={promptGroups} isChatRoute={isChatRoute} isLoading={!!groupsQuery.isLoading} />
</div>
<PanelNavigation
nextPage={nextPage}
prevPage={prevPage}
isFetching={isFetching}
hasNextPage={hasNextPage}
isChatRoute={isChatRoute}
hasPreviousPage={hasPreviousPage}
/>
<div className="flex items-center justify-between">
{isChatRoute && <ManagePrompts className="select-none" />}
<PanelNavigation
nextPage={nextPage}
prevPage={prevPage}
isFetching={isFetching}
hasNextPage={hasNextPage}
isChatRoute={isChatRoute}
hasPreviousPage={hasPreviousPage}
/>
</div>
</div>
);
}

View file

@ -19,11 +19,13 @@ function PanelNavigation({
}) {
const localize = useLocalize();
return (
<div className="my-1 flex justify-between">
<div className="mb-2 flex gap-2">
{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}
</div>
<div className="mb-2 flex gap-2">
<>
<div className="flex gap-2">{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}</div>
<div
className="flex items-center justify-between gap-2"
role="navigation"
aria-label="Pagination"
>
<Button variant="outline" size="sm" onClick={() => prevPage()} disabled={!hasPreviousPage}>
{localize('com_ui_prev')}
</Button>
@ -36,7 +38,7 @@ function PanelNavigation({
{localize('com_ui_next')}
</Button>
</div>
</div>
</>
);
}

View file

@ -143,7 +143,7 @@ export default function VariableForm({
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex],
@ -168,7 +168,7 @@ export default function VariableForm({
return (
<InputCombobox
options={field.config.options || []}
placeholder={localize('com_ui_enter_var', { 0: field.config.variable })}
placeholder={field.config.variable}
className={cn(
defaultTextProps,
'rounded px-3 py-2 focus:bg-surface-tertiary',
@ -191,7 +191,7 @@ export default function VariableForm({
defaultTextProps,
'rounded px-3 py-2 focus:bg-surface-tertiary',
)}
placeholder={localize('com_ui_enter_var', { 0: field.config.variable })}
placeholder={field.config.variable}
maxRows={8}
/>
);

View file

@ -55,7 +55,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[
/** @ts-ignore */

View file

@ -130,7 +130,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
[remarkMath, { singleDollarTextMath: false }],
]}
/** @ts-ignore */
rehypePlugins={rehypePlugins}

View file

@ -1,19 +1,17 @@
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
import ManagePrompts from '~/components/Prompts/ManagePrompts';
import { usePromptGroupsNav } from '~/hooks';
export default function PromptsAccordion() {
const groupsNav = usePromptGroupsNav();
return (
<div className="flex h-full w-full flex-col">
<PromptSidePanel className="lg:w-full xl:w-full" {...groupsNav}>
<div className="flex w-full flex-row items-center justify-between pt-2">
<ManagePrompts className="select-none" />
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center" />
<div className="flex w-full flex-row items-center justify-end">
<AutoSendPrompt className="text-xs dark:text-white" />
</div>
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center" />
</PromptSidePanel>
</div>
);

View file

@ -1,31 +1,27 @@
import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import {
AuthTypeEnum,
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import { ChevronLeft } from 'lucide-react';
import type { AgentPanelProps, ActionAuthForm } from '~/common';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useDeleteAgentAction } from '~/data-provider';
import type { ActionAuthForm } from '~/common';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import ActionsInput from './ActionsInput';
import { Panel } from '~/common';
export default function ActionsPanel({
// activePanel,
action,
setAction,
agent_id,
setActivePanel,
}: AgentPanelProps) {
export default function ActionsPanel() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { setActivePanel, action, setAction, agent_id } = useAgentPanelContext();
const deleteAgentAction = useDeleteAgentAction({
onSuccess: () => {
showToast({
@ -62,7 +58,7 @@ export default function ActionsPanel({
},
});
const { reset, watch } = methods;
const { reset } = methods;
useEffect(() => {
if (action?.metadata.auth) {
@ -128,7 +124,7 @@ export default function ActionsPanel({
selectHandler: () => {
if (!agent_id) {
return showToast({
message: 'No agent_id found, is the agent created?',
message: localize('com_agents_no_agent_id_error'),
status: 'error',
});
}

View file

@ -1,11 +1,9 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Controller, useWatch, useFormContext } from 'react-hook-form';
import { QueryKeys, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
import { useToastContext, useFileMapContext } from '~/Providers';
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { icons } from '~/hooks/Endpoint/Icons';
@ -29,23 +27,16 @@ const inputClass = cn(
);
export default function AgentConfig({
setAction,
actions = [],
agentsConfig,
createMutation,
setActivePanel,
endpointsConfig,
}: AgentPanelProps) {
const fileMap = useFileMapContext();
const queryClient = useQueryClient();
const allTools = queryClient.getQueryData<TPlugin[]>([QueryKeys.tools]) ?? [];
const { showToast } = useToastContext();
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
const localize = useLocalize();
const [showToolDialog, setShowToolDialog] = useState(false);
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
const methods = useFormContext<AgentForm>();
const [showToolDialog, setShowToolDialog] = useState(false);
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
const { control } = methods;
const provider = useWatch({ control, name: 'provider' });
@ -172,6 +163,20 @@ export default function AgentConfig({
Icon = icons[iconKey];
}
// Determine what to show
const selectedToolIds = tools ?? [];
const visibleToolIds = new Set(selectedToolIds);
// Check what group parent tools should be shown if any subtool is present
Object.entries(allTools ?? {}).forEach(([toolId, toolObj]) => {
if (toolObj.tools?.length) {
// if any subtool of this group is selected, ensure group parent tool rendered
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
visibleToolIds.add(toolId);
}
}
});
return (
<>
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
@ -290,28 +295,38 @@ export default function AgentConfig({
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
${actionsEnabled === true ? localize('com_assistants_actions') : ''}`}
</label>
<div className="space-y-2">
{tools?.map((func, i) => (
<AgentTool
key={`${func}-${i}-${agent_id}`}
tool={func}
allTools={allTools}
agent_id={agent_id}
/>
))}
{actions
.filter((action) => action.agent_id === agent_id)
.map((action, i) => (
<Action
key={i}
action={action}
onClick={() => {
setAction(action);
setActivePanel(Panel.actions);
}}
/>
))}
<div className="flex space-x-2">
<div>
<div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => {
if (!allTools) return null;
const tool = allTools[toolId];
if (!tool) return null;
return (
<AgentTool
key={`${toolId}-${i}-${agent_id}`}
tool={toolId}
allTools={allTools}
agent_id={agent_id}
/>
);
})}
</div>
<div className="flex flex-col gap-1">
{(actions ?? [])
.filter((action) => action.agent_id === agent_id)
.map((action, i) => (
<Action
key={i}
action={action}
onClick={() => {
setAction(action);
setActivePanel(Panel.actions);
}}
/>
))}
</div>
<div className="mt-2 flex space-x-2">
{(toolsEnabled ?? false) && (
<button
type="button"
@ -340,11 +355,12 @@ export default function AgentConfig({
</div>
</div>
</div>
{/* MCP Section */}
{/* <MCPSection /> */}
</div>
<ToolSelectDialog
isOpen={showToolDialog}
setIsOpen={setShowToolDialog}
toolsFormKey="tools"
endpoint={EModelEndpoint.agents}
/>
</>

View file

@ -7,20 +7,22 @@ import {
Constants,
SystemRoles,
EModelEndpoint,
TAgentsEndpoint,
TEndpointsConfig,
isAssistantsEndpoint,
defaultAgentFormValues,
} from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, StringOption } from '~/common';
import type { AgentForm, StringOption } from '~/common';
import {
useCreateAgentMutation,
useUpdateAgentMutation,
useGetAgentByIdQuery,
} from '~/data-provider';
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import AgentPanelSkeleton from './AgentPanelSkeleton';
import { createProviderOption } from '~/utils';
import { useToastContext } from '~/Providers';
import AdvancedPanel from './Advanced/AdvancedPanel';
import { useToastContext } from '~/Providers';
import AgentConfig from './AgentConfig';
import AgentSelect from './AgentSelect';
import AgentFooter from './AgentFooter';
@ -29,18 +31,21 @@ import ModelPanel from './ModelPanel';
import { Panel } from '~/common';
export default function AgentPanel({
setAction,
activePanel,
actions = [],
setActivePanel,
agent_id: current_agent_id,
setCurrentAgentId,
agentsConfig,
endpointsConfig,
}: AgentPanelProps) {
}: {
agentsConfig: TAgentsEndpoint | null;
endpointsConfig: TEndpointsConfig;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const {
activePanel,
setActivePanel,
setCurrentAgentId,
agent_id: current_agent_id,
} = useAgentPanelContext();
const { onSelect: onSelectAgent } = useSelectAgent();
@ -51,7 +56,7 @@ export default function AgentPanel({
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
const methods = useForm<AgentForm>({
defaultValues: defaultAgentFormValues,
defaultValues: getDefaultAgentFormValues(),
});
const { control, handleSubmit, reset } = methods;
@ -277,7 +282,7 @@ export default function AgentPanel({
variant="outline"
className="w-full justify-center"
onClick={() => {
reset(defaultAgentFormValues);
reset(getDefaultAgentFormValues());
setCurrentAgentId(undefined);
}}
disabled={agentQuery.isInitialLoading}
@ -315,22 +320,13 @@ export default function AgentPanel({
</div>
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.model && (
<ModelPanel
setActivePanel={setActivePanel}
agent_id={agent_id}
providers={providers}
models={models}
/>
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
<AgentConfig
actions={actions}
setAction={setAction}
createMutation={create}
agentsConfig={agentsConfig}
setActivePanel={setActivePanel}
endpointsConfig={endpointsConfig}
setCurrentAgentId={setCurrentAgentId}
/>
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (

View file

@ -1,22 +1,29 @@
import { useState, useEffect, useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { ActionsEndpoint } from '~/common';
import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider';
import type { TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider';
import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useGetEndpointsQuery } from '~/data-provider';
import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel';
import VersionPanel from './Version/VersionPanel';
import MCPPanel from './MCPPanel';
import { Panel } from '~/common';
export default function AgentPanelSwitch() {
const { conversation, index } = useChatContext();
const [activePanel, setActivePanel] = useState(Panel.builder);
const [action, setAction] = useState<Action | undefined>(undefined);
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
return (
<AgentPanelProvider>
<AgentPanelSwitchWithContext />
</AgentPanelProvider>
);
}
function AgentPanelSwitchWithContext() {
const { conversation } = useChatContext();
const { activePanel, setCurrentAgentId } = useAgentPanelContext();
// TODO: Implement MCP endpoint
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const createMutation = useCreateAgentMutation();
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
@ -35,39 +42,20 @@ export default function AgentPanelSwitch() {
if (agent_id) {
setCurrentAgentId(agent_id);
}
}, [conversation?.agent_id]);
}, [setCurrentAgentId, conversation?.agent_id]);
if (!conversation?.endpoint) {
return null;
}
const commonProps = {
index,
action,
actions,
setAction,
activePanel,
setActivePanel,
setCurrentAgentId,
agent_id: currentAgentId,
createMutation,
};
if (activePanel === Panel.actions) {
return <ActionsPanel {...commonProps} />;
return <ActionsPanel />;
}
if (activePanel === Panel.version) {
return (
<VersionPanel
setActivePanel={setActivePanel}
agentsConfig={agentsConfig}
selectedAgentId={currentAgentId}
/>
);
return <VersionPanel />;
}
return (
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
);
if (activePanel === Panel.mcp) {
return <MCPPanel />;
}
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
}

View file

@ -5,8 +5,8 @@ import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provid
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
import type { TAgentCapabilities, AgentForm } from '~/common';
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider';
import { cn, createProviderOption, processAgentOption } from '~/utils';
import ControlCombobox from '~/components/ui/ControlCombobox';
import { useLocalize } from '~/hooks';
@ -32,7 +32,10 @@ export default function AgentSelect({
select: (res) =>
res.data.map((agent) =>
processAgentOption({
agent,
agent: {
...agent,
name: agent.name || agent.id,
},
instanceProjectId: startupConfig?.instanceProjectId,
}),
),
@ -124,9 +127,7 @@ export default function AgentSelect({
createMutation.reset();
if (!agentExists) {
setCurrentAgentId(undefined);
return reset({
...defaultAgentFormValues,
});
return reset(getDefaultAgentFormValues());
}
setCurrentAgentId(selectedId);
@ -179,7 +180,7 @@ export default function AgentSelect({
containerClassName="px-0"
selectedValue={(field?.value?.value ?? '') + ''}
displayValue={field?.value?.label ?? ''}
selectPlaceholder={createAgent}
selectPlaceholder={field?.value?.value ?? createAgent}
iconSide="right"
searchPlaceholder={localize('com_agents_search_name')}
SelectIcon={field?.value?.icon}

View file

@ -1,41 +1,71 @@
import React, { useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import type { TPlugin } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import type { AgentToolType } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
import { OGDialog, OGDialogTrigger, Label, Checkbox } from '~/components/ui';
import { TrashIcon, CircleHelpIcon } from '~/components/svg';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function AgentTool({
tool,
allTools,
agent_id = '',
}: {
tool: string;
allTools: TPlugin[];
allTools?: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
agent_id?: string;
}) {
const [isHovering, setIsHovering] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
const [accordionValue, setAccordionValue] = useState<string>('');
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext();
const currentTool = allTools.find((t) => t.pluginKey === tool);
const { getValues, setValue } = useFormContext<AgentForm>();
if (!allTools) {
return null;
}
const currentTool = allTools[tool];
const getSelectedTools = () => {
if (!currentTool?.tools) return [];
const formTools = getValues('tools') || [];
return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
};
const updateFormTools = (newSelectedTools: string[]) => {
const currentTools = getValues('tools') || [];
const otherTools = currentTools.filter(
(t: string) => !currentTool?.tools?.some((st) => st.tool_id === t),
);
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (toolId: string) => {
if (toolId) {
const toolIdsToRemove =
isGroup && currentTool.tools
? [toolId, ...currentTool.tools.map((t) => t.tool_id)]
: [toolId];
const removeTool = (tool: string) => {
if (tool) {
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const tools = getValues('tools').filter((fn: string) => fn !== tool);
setValue('tools', tools);
const remainingToolIds = getValues('tools')?.filter(
(toolId: string) => !toolIdsToRemove.includes(toolId),
);
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
@ -47,41 +77,309 @@ export default function AgentTool({
return null;
}
return (
<OGDialog>
<div
className={cn('flex w-full items-center rounded-lg text-sm', !agent_id ? 'opacity-40' : '')}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="flex grow items-center">
{currentTool.icon && (
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
/>
</div>
)}
<div
className="h-9 grow px-3 py-2"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.name}
</div>
</div>
const isGroup = currentTool.tools && currentTool.tools.length > 0;
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentTool.tool_id;
if (!isGroup) {
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
// Check if focus is moving to a child element
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<div className="flex grow items-center">
{currentTool.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${currentTool.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.metadata.name}
</div>
</div>
{isHovering && (
<OGDialogTrigger asChild>
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
'hover:bg-gray-200 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'focus:opacity-100',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${currentTool.metadata.name}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon />
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
)}
</div>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(currentTool.tool_id),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
}
// Group tool with accordion
return (
<OGDialog>
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
<AccordionItem value={currentTool.tool_id} className="group relative w-full border-none">
<div
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800/50"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
// Check if focus is moving to a child element
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<AccordionPrimitive.Header asChild>
<AccordionPrimitive.Trigger asChild>
<button
type="button"
className={cn(
'flex grow items-center gap-1 rounded bg-transparent p-0 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
)}
>
{currentTool.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${currentTool.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.metadata.name}
</div>
<div className="flex items-center">
{/* Container for grouped checkbox and chevron */}
<div className="relative flex items-center">
{/* Grouped checkbox and chevron that slide together */}
<div
className={cn(
'flex items-center gap-2 transition-all duration-300',
isHovering || isFocused ? '-translate-x-8' : 'translate-x-0',
)}
>
<div
data-checkbox-container
onClick={(e) => e.stopPropagation()}
className="mt-1"
>
<Checkbox
id={`select-all-${currentTool.tool_id}`}
checked={selectedTools.length === currentTool.tools?.length}
onCheckedChange={(checked) => {
if (currentTool.tools) {
const newSelectedTools = checked
? currentTool.tools.map((t) => t.tool_id)
: [];
updateFormTools(newSelectedTools);
}
}}
className={cn(
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
isExpanded ? 'visible' : 'pointer-events-none invisible',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
tabIndex={isExpanded ? 0 : -1}
/>
</div>
<div
className={cn(
'pointer-events-none flex h-4 w-4 items-center justify-center transition-transform duration-300',
isExpanded ? 'rotate-180' : '',
)}
aria-hidden="true"
>
<ChevronDown className="h-4 w-4" />
</div>
</div>
{/* Delete button slides in from behind */}
<div
className={cn(
'absolute right-0 transition-all duration-300',
isHovering || isFocused
? 'translate-x-0 opacity-100'
: 'translate-x-8 opacity-0',
)}
>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'hover:bg-gray-200 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'focus:translate-x-0 focus:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Delete ${currentTool.metadata.name}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
</div>
</div>
</button>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
</div>
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
<div className="space-y-1">
{currentTool.tools?.map((subTool) => (
<label
key={subTool.tool_id}
htmlFor={subTool.tool_id}
className={cn(
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
}}
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
onMouseLeave={() => setHoveredToolId(null)}
>
<Checkbox
id={subTool.tool_id}
checked={selectedTools.includes(subTool.tool_id)}
onCheckedChange={(_checked) => {
const newSelectedTools = selectedTools.includes(subTool.tool_id)
? selectedTools.filter((t) => t !== subTool.tool_id)
: [...selectedTools, subTool.tool_id];
updateFormTools(newSelectedTools);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
onClick={(e) => e.stopPropagation()}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-gray-300 transition-[border-color] duration-200 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background dark:border-gray-600 dark:hover:border-gray-500"
/>
<span className="text-token-text-primary">{subTool.metadata.name}</span>
{subTool.metadata.description && (
<Ariakit.HovercardProvider placement="left-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
className={cn(
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
)}
aria-label={localize('com_ui_tool_info')}
>
<CircleHelpIcon className="h-4 w-4" />
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_info')}
</Ariakit.VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_tool_more_info')}
aria-expanded={hoveredToolId === subTool.tool_id}
aria-controls={`tool-description-${subTool.tool_id}`}
>
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_more_info')}
</Ariakit.VisuallyHidden>
<ChevronDown className="h-4 w-4" />
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
id={`tool-description-${subTool.tool_id}`}
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
portal={true}
unmountOnHide={true}
role="tooltip"
aria-label={subTool.metadata.description}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{subTool.metadata.description}
</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
@ -93,7 +391,7 @@ export default function AgentTool({
</Label>
}
selection={{
selectHandler: () => removeTool(currentTool.pluginKey),
selectHandler: () => removeTool(currentTool.tool_id),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),

View file

@ -15,6 +15,7 @@ export default function ApiKeyDialog({
register,
handleSubmit,
triggerRef,
triggerRefs,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@ -24,7 +25,8 @@ export default function ApiKeyDialog({
isToolAuthenticated: boolean;
register: UseFormRegister<ApiKeyFormData>;
handleSubmit: UseFormHandleSubmit<ApiKeyFormData>;
triggerRef?: RefObject<HTMLInputElement>;
triggerRef?: RefObject<HTMLInputElement | HTMLButtonElement>;
triggerRefs?: RefObject<HTMLInputElement | HTMLButtonElement>[];
}) {
const localize = useLocalize();
const languageIcons = [
@ -41,7 +43,12 @@ export default function ApiKeyDialog({
];
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialog
open={isOpen}
onOpenChange={onOpenChange}
triggerRef={triggerRef}
triggerRefs={triggerRefs}
>
<OGDialogTemplate
className="w-11/12 sm:w-[450px]"
title=""

View file

@ -1,12 +1,11 @@
import { useFormContext } from 'react-hook-form';
import { defaultAgentFormValues } from 'librechat-data-provider';
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import { cn, logger, removeFocusOutlines, getDefaultAgentFormValues } from '~/utils';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import { useChatContext, useToastContext } from '~/Providers';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useChatContext, useToastContext } from '~/Providers';
import { useLocalize, useSetIndexOptions } from '~/hooks';
import { cn, removeFocusOutlines, logger } from '~/utils';
import { useDeleteAgentMutation } from '~/data-provider';
import { TrashIcon } from '~/components/svg';
@ -45,9 +44,7 @@ export default function DeleteButton({
const firstAgent = updatedList[0] as Agent | undefined;
if (!firstAgent) {
setCurrentAgentId(undefined);
reset({
...defaultAgentFormValues,
});
reset(getDefaultAgentFormValues());
return setOption('agent_id')('');
}

View file

@ -50,6 +50,7 @@ export const AgentAvatarRender = ({
width="80"
height="80"
style={{ opacity: progress < 1 ? 0.4 : 1 }}
key={url || 'default-key'}
/>
{progress < 1 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/5 text-white">

View file

@ -0,0 +1,64 @@
import { useState, useEffect, useRef } from 'react';
import SquirclePlusIcon from '~/components/svg/SquirclePlusIcon';
import { useLocalize } from '~/hooks';
interface MCPIconProps {
icon?: string;
onIconChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
const [previewUrl, setPreviewUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const localize = useLocalize();
useEffect(() => {
if (icon) {
setPreviewUrl(icon);
} else {
setPreviewUrl('');
}
}, [icon]);
const handleClick = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.click();
}
};
return (
<div className="flex items-center gap-4">
<div
onClick={handleClick}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-[1.5rem] border-2 border-dashed"
>
{previewUrl ? (
<img
src={previewUrl}
className="h-full w-full rounded-[1.5rem] object-cover"
alt="MCP Icon"
width="64"
height="64"
/>
) : (
<SquirclePlusIcon />
)}
</div>
<div className="flex flex-col gap-1">
<span className="token-text-secondary text-sm">
{localize('com_ui_icon')} {localize('com_ui_optional')}
</span>
<span className="token-text-tertiary text-xs">{localize('com_agents_mcp_icon_size')}</span>
</div>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={onIconChange}
ref={fileInputRef}
/>
</div>
);
}

View file

@ -0,0 +1,288 @@
import { useState, useEffect } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import type { MCP } from 'librechat-data-provider';
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { Label, Checkbox } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { MCPForm } from '~/common/types';
function useUpdateAgentMCP({
onSuccess,
onError,
}: {
onSuccess: (data: [string, MCP]) => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({
mcp_id,
metadata,
agent_id,
}: {
mcp_id?: string;
metadata: MCP['metadata'];
agent_id: string;
}) => {
try {
// TODO: Implement MCP endpoint
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
} catch (error) {
onError(error as Error);
}
},
isLoading: false,
};
}
interface MCPInputProps {
mcp?: MCP;
agent_id?: string;
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>;
}
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
handleSubmit,
register,
formState: { errors },
control,
} = useFormContext<MCPForm>();
const [isLoading, setIsLoading] = useState(false);
const [showTools, setShowTools] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
// Initialize tools list if editing existing MCP
useEffect(() => {
if (mcp?.mcp_id && mcp.metadata.tools) {
setShowTools(true);
setSelectedTools(mcp.metadata.tools);
}
}, [mcp]);
const updateAgentMCP = useUpdateAgentMCP({
onSuccess(data) {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setMCP(data[1]);
setShowTools(true);
setSelectedTools(data[1].metadata.tools ?? []);
setIsLoading(false);
},
onError(error) {
showToast({
message: (error as Error).message || localize('com_ui_update_mcp_error'),
status: 'error',
});
setIsLoading(false);
},
});
const saveMCP = handleSubmit(async (data: MCPForm) => {
setIsLoading(true);
try {
const response = await updateAgentMCP.mutate({
agent_id: agent_id ?? '',
mcp_id: mcp?.mcp_id,
metadata: {
...data,
tools: selectedTools,
},
});
setMCP(response[1]);
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
} catch {
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
} finally {
setIsLoading(false);
}
});
const handleSelectAll = () => {
if (mcp?.metadata.tools) {
setSelectedTools(mcp.metadata.tools);
}
};
const handleDeselectAll = () => {
setSelectedTools([]);
};
const handleToolToggle = (tool: string) => {
setSelectedTools((prev) =>
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool],
);
};
const handleToggleAll = () => {
if (selectedTools.length === mcp?.metadata.tools?.length) {
handleDeselectAll();
} else {
handleSelectAll();
}
};
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setMCP({
mcp_id: mcp?.mcp_id ?? '',
agent_id: agent_id ?? '',
metadata: {
...mcp?.metadata,
icon: base64String,
},
});
};
reader.readAsDataURL(file);
}
};
return (
<div className="flex flex-col gap-4">
{/* Icon Picker */}
<div className="mb-4">
<MCPIcon icon={mcp?.metadata.icon} onIconChange={handleIconChange} />
</div>
{/* name, description, url */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="name">{localize('com_ui_name')}</Label>
<input
id="name"
{...register('name', { required: true })}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={localize('com_agents_mcp_name_placeholder')}
/>
{errors.name && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="description">
{localize('com_ui_description')}
<span className="ml-1 text-xs text-text-secondary-alt">
{localize('com_ui_optional')}
</span>
</Label>
<input
id="description"
{...register('description')}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={localize('com_agents_mcp_description_placeholder')}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="url">{localize('com_ui_mcp_url')}</Label>
<input
id="url"
{...register('url', {
required: true,
})}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={'https://mcp.example.com'}
/>
{errors.url && (
<span className="text-xs text-red-500">
{errors.url.type === 'required'
? localize('com_ui_field_required')
: errors.url.message}
</span>
)}
</div>
<MCPAuth />
<div className="my-2 flex items-center gap-2">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="trust" className="flex flex-col">
{localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</Label>
</div>
{errors.trust && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
</div>
<div className="flex items-center justify-end">
<button
onClick={saveMCP}
disabled={isLoading}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
type="button"
>
{(() => {
if (isLoading) {
return <Spinner className="icon-md" />;
}
return mcp?.mcp_id ? localize('com_ui_update') : localize('com_ui_create');
})()}
</button>
</div>
{showTools && mcp?.metadata.tools && (
<div className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-token-text-primary block font-medium">
{localize('com_ui_available_tools')}
</h3>
<button
onClick={handleToggleAll}
type="button"
className="btn btn-neutral border-token-border-light relative h-8 rounded-full px-4 font-medium"
>
{selectedTools.length === mcp.metadata.tools.length
? localize('com_ui_deselect_all')
: localize('com_ui_select_all')}
</button>
</div>
<div className="flex flex-col gap-2">
{mcp.metadata.tools.map((tool) => (
<label
key={tool}
htmlFor={tool}
className="border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2"
>
<Checkbox
id={tool}
checked={selectedTools.includes(tool)}
onCheckedChange={() => handleToolToggle(tool)}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
/>
<span className="text-token-text-primary">
{tool
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')}
</span>
</label>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,172 @@
import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { defaultMCPFormValues } from '~/common/mcp';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import type { MCPForm } from '~/common';
import MCPInput from './MCPInput';
import { Panel } from '~/common';
import {
AuthTypeEnum,
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({
onSuccess,
onError,
}: {
onSuccess: () => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
try {
console.log('Mock delete MCP:', { mcp_id, agent_id });
onSuccess();
} catch (error) {
onError(error as Error);
}
},
};
}
export default function MCPPanel() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
const deleteAgentMCP = useDeleteAgentMCP({
onSuccess: () => {
showToast({
message: localize('com_ui_delete_mcp_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setMcp(undefined);
},
onError(error) {
showToast({
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
status: 'error',
});
},
});
const methods = useForm<MCPForm>({
defaultValues: defaultMCPFormValues,
});
const { reset } = methods;
useEffect(() => {
if (mcp) {
const formData = {
icon: mcp.metadata.icon ?? '',
name: mcp.metadata.name ?? '',
description: mcp.metadata.description ?? '',
url: mcp.metadata.url ?? '',
tools: mcp.metadata.tools ?? [],
trust: mcp.metadata.trust ?? false,
};
if (mcp.metadata.auth) {
Object.assign(formData, {
type: mcp.metadata.auth.type || AuthTypeEnum.None,
saved_auth_fields: false,
api_key: mcp.metadata.api_key ?? '',
authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
oauth_client_id: mcp.metadata.oauth_client_id ?? '',
oauth_client_secret: mcp.metadata.oauth_client_secret ?? '',
authorization_url: mcp.metadata.auth.authorization_url ?? '',
client_url: mcp.metadata.auth.client_url ?? '',
scope: mcp.metadata.auth.scope ?? '',
token_exchange_method:
mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
});
}
reset(formData);
}
}, [mcp, reset]);
return (
<FormProvider {...methods}>
<form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto px-2 pb-12 text-sm">
<div className="relative flex flex-col items-center px-16 py-6 text-center">
<div className="absolute left-0 top-6">
<button
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
setMcp(undefined);
}}
>
<div className="flex w-full items-center justify-center gap-2">
<ChevronLeft />
</div>
</button>
</div>
{!!mcp && (
<OGDialog>
<OGDialogTrigger asChild>
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !mcp.mcp_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
</button>
</div>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_mcp')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_mcp_confirm')}
</Label>
}
selection={{
selectHandler: () => {
if (!agent_id) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
});
}
deleteAgentMCP.mutate({
mcp_id: mcp.mcp_id,
agent_id,
});
},
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
<div className="text-xl font-medium">
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
</div>
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
</div>
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
</div>
</form>
</FormProvider>
);
}

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