🪄 feat: Artifacts Badge & Optimize Ephemeral Agent State (#8252)

* 🔧 fix: Update type annotations in useEventHandlers for better type safety

* 🔧 refactor: `useToolToggle` for improved localStorage synchronization and allow string/falsy values for setting to storage

*  feat: Implement Artifacts badge to BadgeRow with toggle options and UI components

- Added Artifacts component to manage artifacts state and options.
- Introduced ArtifactsSubMenu for additional settings related to artifacts.
- Integrated artifacts functionality into BadgeRow and ToolsDropdown components.
- Updated localStorage handling for artifacts state persistence.
- Enhanced localization for artifacts-related strings in translation files.
- Refactored Agent model to include artifacts in the ephemeral agent response.

* fix: set ephemeral agent state for conversation on finalization

* chore: remove beta settings dialog tab

* refactor: improve Ephemeral Agent statefulness

* fix: update setValue parameter to use 'value' instead of 'isChecked' in CheckboxButton

* refactor: update color classes for Artifact toggle and order of dropdown components

* chore: remove unused i18n localization
This commit is contained in:
Danny Avila 2025-07-04 13:23:37 -04:00
parent 458580ec87
commit a288ad1d9c
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
23 changed files with 547 additions and 232 deletions

View file

@ -90,7 +90,7 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
}
const instructions = req.body.promptPrefix;
return {
const result = {
id: agent_id,
instructions,
provider: endpoint,
@ -98,6 +98,11 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
model,
tools,
};
if (ephemeralAgent?.artifacts != null && ephemeralAgent.artifacts) {
result.artifacts = ephemeralAgent.artifacts;
}
return result;
};
/**

View file

@ -1,7 +1,9 @@
import React, { createContext, useContext } from 'react';
import { Tools, LocalStorageKeys } from 'librechat-data-provider';
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { Tools, LocalStorageKeys, AgentCapabilities, Constants } from 'librechat-data-provider';
import { useMCPSelect, useToolToggle, useCodeApiKeyForm, useSearchApiKeyForm } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { useSetRecoilState } from 'recoil';
import { ephemeralAgentByConvoId } from '~/store';
interface BadgeRowContextType {
conversationId?: string | null;
@ -9,6 +11,7 @@ interface BadgeRowContextType {
webSearch: ReturnType<typeof useToolToggle>;
codeInterpreter: ReturnType<typeof useToolToggle>;
fileSearch: ReturnType<typeof useToolToggle>;
artifacts: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
startupConfig: ReturnType<typeof useGetStartupConfig>['data'];
@ -26,10 +29,87 @@ export function useBadgeRowContext() {
interface BadgeRowProviderProps {
children: React.ReactNode;
isSubmitting?: boolean;
conversationId?: string | null;
}
export default function BadgeRowProvider({ children, conversationId }: BadgeRowProviderProps) {
export default function BadgeRowProvider({
children,
isSubmitting,
conversationId,
}: BadgeRowProviderProps) {
const hasInitializedRef = useRef(false);
const lastKeyRef = useRef<string>('');
const key = conversationId ?? Constants.NEW_CONVO;
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key));
/** Initialize ephemeralAgent from localStorage on mount and when conversation changes */
useEffect(() => {
if (isSubmitting) {
return;
}
// Check if this is a new conversation or the first load
if (!hasInitializedRef.current || lastKeyRef.current !== key) {
hasInitializedRef.current = true;
lastKeyRef.current = key;
// Load all localStorage values
const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`;
const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`;
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`;
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`;
const codeToggleValue = localStorage.getItem(codeToggleKey);
const webSearchToggleValue = localStorage.getItem(webSearchToggleKey);
const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey);
const artifactsToggleValue = localStorage.getItem(artifactsToggleKey);
const initialValues: Record<string, any> = {};
if (codeToggleValue !== null) {
try {
initialValues[Tools.execute_code] = JSON.parse(codeToggleValue);
} catch (e) {
console.error('Failed to parse code toggle value:', e);
}
}
if (webSearchToggleValue !== null) {
try {
initialValues[Tools.web_search] = JSON.parse(webSearchToggleValue);
} catch (e) {
console.error('Failed to parse web search toggle value:', e);
}
}
if (fileSearchToggleValue !== null) {
try {
initialValues[Tools.file_search] = JSON.parse(fileSearchToggleValue);
} catch (e) {
console.error('Failed to parse file search toggle value:', e);
}
}
if (artifactsToggleValue !== null) {
try {
initialValues[AgentCapabilities.artifacts] = JSON.parse(artifactsToggleValue);
} catch (e) {
console.error('Failed to parse artifacts toggle value:', e);
}
}
// Always set values for all tools (use defaults if not in localStorage)
// If ephemeralAgent is null, create a new object with just our tool values
setEphemeralAgent((prev) => ({
...(prev || {}),
[Tools.execute_code]: initialValues[Tools.execute_code] ?? false,
[Tools.web_search]: initialValues[Tools.web_search] ?? false,
[Tools.file_search]: initialValues[Tools.file_search] ?? false,
[AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false,
}));
}
}, [key, isSubmitting, setEphemeralAgent]);
/** Startup config */
const { data: startupConfig } = useGetStartupConfig();
@ -74,10 +154,19 @@ export default function BadgeRowProvider({ children, conversationId }: BadgeRowP
isAuthenticated: true,
});
/** Artifacts hook - using a custom key since it's not a Tool but a capability */
const artifacts = useToolToggle({
conversationId,
toolKey: AgentCapabilities.artifacts,
localStorageKey: LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
isAuthenticated: true,
});
const value: BadgeRowContextType = {
mcpSelect,
webSearch,
fileSearch,
artifacts,
startupConfig,
conversationId,
codeApiKeyForm,

View file

@ -0,0 +1,152 @@
import React, { memo, useState, useCallback, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { ArtifactModes } from 'librechat-data-provider';
import { WandSparkles, ChevronDown } from 'lucide-react';
import CheckboxButton from '~/components/ui/CheckboxButton';
import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ArtifactsToggleState {
enabled: boolean;
mode: string;
}
function Artifacts() {
const localize = useLocalize();
const { artifacts } = useBadgeRowContext();
const { toggleState, debouncedChange, isPinned } = artifacts;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const currentState = useMemo<ArtifactsToggleState>(() => {
if (typeof toggleState === 'string' && toggleState) {
return { enabled: true, mode: toggleState };
}
return { enabled: false, mode: '' };
}, [toggleState]);
const isEnabled = currentState.enabled;
const isShadcnEnabled = currentState.mode === ArtifactModes.SHADCNUI;
const isCustomEnabled = currentState.mode === ArtifactModes.CUSTOM;
const handleToggle = useCallback(() => {
if (isEnabled) {
debouncedChange({ value: '' });
} else {
debouncedChange({ value: ArtifactModes.DEFAULT });
}
}, [isEnabled, debouncedChange]);
const handleShadcnToggle = useCallback(() => {
if (isShadcnEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
debouncedChange({ value: ArtifactModes.SHADCNUI });
}
}, [isShadcnEnabled, debouncedChange]);
const handleCustomToggle = useCallback(() => {
if (isCustomEnabled) {
debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
debouncedChange({ value: ArtifactModes.CUSTOM });
}
}, [isCustomEnabled, debouncedChange]);
if (!isEnabled && !isPinned) {
return null;
}
return (
<div className="flex">
<CheckboxButton
className={cn('max-w-fit', isEnabled && 'rounded-r-none border-r-0')}
checked={isEnabled}
setValue={handleToggle}
label={localize('com_ui_artifacts')}
isCheckedClassName="border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10"
icon={<WandSparkles className="icon-md" />}
/>
{isEnabled && (
<Ariakit.MenuProvider open={isPopoverOpen} setOpen={setIsPopoverOpen}>
<Ariakit.MenuButton
className={cn(
'w-7 rounded-l-none rounded-r-full border-b border-l-0 border-r border-t border-border-light md:w-6',
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
'transition-colors',
)}
onClick={(e) => e.stopPropagation()}
>
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" />
</Ariakit.MenuButton>
<Ariakit.Menu
gutter={8}
className={cn(
'animate-popover z-50 flex max-h-[300px]',
'flex-col overflow-auto overscroll-contain rounded-xl',
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
'border border-border-light',
'min-w-[250px] outline-none',
)}
portal
>
<div className="px-2 py-1.5">
<div className="mb-2 text-xs font-medium text-text-secondary">
{localize('com_ui_artifacts_options')}
</div>
{/* Include shadcn/ui Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShadcnToggle();
}}
disabled={isCustomEnabled}
className={cn(
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer 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',
isCustomEnabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
</div>
</Ariakit.MenuItem>
{/* Custom Prompt Mode Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCustomToggle();
}}
className={cn(
'flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer 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',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
</div>
</Ariakit.MenuItem>
</div>
</Ariakit.Menu>
</Ariakit.MenuProvider>
)}
</div>
);
}
export default memo(Artifacts);

View file

@ -0,0 +1,147 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight, WandSparkles } from 'lucide-react';
import { ArtifactModes } from 'librechat-data-provider';
import { PinIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ArtifactsSubMenuProps {
isArtifactsPinned: boolean;
setIsArtifactsPinned: (value: boolean) => void;
artifactsMode: string;
handleArtifactsToggle: () => void;
handleShadcnToggle: () => void;
handleCustomToggle: () => void;
}
const ArtifactsSubMenu = ({
isArtifactsPinned,
setIsArtifactsPinned,
artifactsMode,
handleArtifactsToggle,
handleShadcnToggle,
handleCustomToggle,
...props
}: ArtifactsSubMenuProps) => {
const localize = useLocalize();
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
showTimeout: 100,
placement: 'right',
});
const isEnabled = artifactsMode !== '' && artifactsMode !== undefined;
const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI;
const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM;
return (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleArtifactsToggle();
}}
onMouseEnter={() => {
if (isEnabled) {
menuStore.show();
}
}}
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">
<WandSparkles className="icon-md" />
<span>{localize('com_ui_artifacts')}</span>
{isEnabled && <ChevronRight className="ml-auto h-3 w-3" />}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsArtifactsPinned(!isArtifactsPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isArtifactsPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isArtifactsPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isArtifactsPinned} />
</div>
</button>
</Ariakit.MenuItem>
{isEnabled && (
<Ariakit.Menu
portal={true}
unmountOnHide={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
)}
>
<div className="px-2 py-1.5">
<div className="mb-2 text-xs font-medium text-text-secondary">
{localize('com_ui_artifacts_options')}
</div>
{/* Include shadcn/ui Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShadcnToggle();
}}
disabled={isCustomEnabled}
className={cn(
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary 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',
isCustomEnabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
</div>
</Ariakit.MenuItem>
{/* Custom Prompt Mode Option */}
<Ariakit.MenuItem
hideOnClick={false}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleCustomToggle();
}}
className={cn(
'flex items-center justify-between rounded-lg px-2 py-2',
'cursor-pointer text-text-primary 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',
)}
>
<div className="flex items-center gap-2">
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
</div>
</Ariakit.MenuItem>
</div>
</Ariakit.Menu>
)}
</Ariakit.MenuProvider>
);
};
export default React.memo(ArtifactsSubMenu);

View file

@ -18,6 +18,7 @@ import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import ToolDialogs from './ToolDialogs';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import MCPSelect from './MCPSelect';
import WebSearch from './WebSearch';
import store from '~/store';
@ -27,6 +28,7 @@ interface BadgeRowProps {
onChange: (badges: Pick<BadgeItem, 'id'>[]) => void;
onToggle?: (badgeId: string, currentActive: boolean) => void;
conversationId?: string | null;
isSubmitting?: boolean;
isInChat: boolean;
}
@ -140,6 +142,7 @@ const dragReducer = (state: DragState, action: DragAction): DragState => {
function BadgeRow({
showEphemeralBadges,
conversationId,
isSubmitting,
onChange,
onToggle,
isInChat,
@ -317,7 +320,7 @@ function BadgeRow({
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
return (
<BadgeRowProvider conversationId={conversationId}>
<BadgeRowProvider conversationId={conversationId} isSubmitting={isSubmitting}>
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{showEphemeralBadges === true && <ToolsDropdown />}
{tempBadges.map((badge, index) => (
@ -364,6 +367,7 @@ function BadgeRow({
<WebSearch />
<CodeInterpreter />
<FileSearch />
<Artifacts />
<MCPSelect />
</>
)}

View file

@ -305,6 +305,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
</div>
<BadgeRow
showEphemeralBadges={!isAgentsEndpoint(endpoint) && !isAssistantsEndpoint(endpoint)}
isSubmitting={isSubmitting || isSubmittingAdded}
conversationId={conversationId}
onChange={setBadges}
isInChat={

View file

@ -2,8 +2,9 @@ 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 { Permissions, PermissionTypes, AuthType, ArtifactModes } from 'librechat-data-provider';
import { TooltipAnchor, DropdownPopup } from '~/components';
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
import { PinIcon, VectorIcon } from '~/components/svg';
import { useLocalize, useHasAccess } from '~/hooks';
@ -21,6 +22,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const {
webSearch,
mcpSelect,
artifacts,
fileSearch,
startupConfig,
codeApiKeyForm,
@ -42,6 +44,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
authData: codeAuthData,
} = codeInterpreter;
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
const {
mcpValues,
mcpServerNames,
@ -72,19 +75,46 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const handleWebSearchToggle = useCallback(() => {
const newValue = !webSearch.toggleState;
webSearch.debouncedChange({ isChecked: newValue });
webSearch.debouncedChange({ value: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ isChecked: newValue });
codeInterpreter.debouncedChange({ value: newValue });
}, [codeInterpreter]);
const handleFileSearchToggle = useCallback(() => {
const newValue = !fileSearch.toggleState;
fileSearch.debouncedChange({ isChecked: newValue });
fileSearch.debouncedChange({ value: newValue });
}, [fileSearch]);
const handleArtifactsToggle = useCallback(() => {
const currentState = artifacts.toggleState;
if (!currentState || currentState === '') {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: '' });
}
}, [artifacts]);
const handleShadcnToggle = useCallback(() => {
const currentState = artifacts.toggleState;
if (currentState === ArtifactModes.SHADCNUI) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.SHADCNUI });
}
}, [artifacts]);
const handleCustomToggle = useCallback(() => {
const currentState = artifacts.toggleState;
if (currentState === ArtifactModes.CUSTOM) {
artifacts.debouncedChange({ value: ArtifactModes.DEFAULT });
} else {
artifacts.debouncedChange({ value: ArtifactModes.CUSTOM });
}
}, [artifacts]);
const handleMCPToggle = useCallback(
(serverName: string) => {
const currentValues = mcpSelect.mcpValues ?? [];
@ -238,6 +268,22 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
});
}
// Add Artifacts option
items.push({
hideOnClick: false,
render: (props) => (
<ArtifactsSubMenu
{...props}
isArtifactsPinned={isArtifactsPinned}
setIsArtifactsPinned={setIsArtifactsPinned}
artifactsMode={artifacts.toggleState as string}
handleArtifactsToggle={handleArtifactsToggle}
handleShadcnToggle={handleShadcnToggle}
handleCustomToggle={handleCustomToggle}
/>
),
});
if (mcpServerNames && mcpServerNames.length > 0) {
items.push({
hideOnClick: false,
@ -271,15 +317,21 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
handleMCPToggle,
showCodeSettings,
setIsSearchPinned,
handleShadcnToggle,
handleCustomToggle,
isFileSearchPinned,
isArtifactsPinned,
codeMenuTriggerRef,
setIsCodeDialogOpen,
searchMenuTriggerRef,
showWebSearchSettings,
setIsFileSearchPinned,
artifacts.toggleState,
setIsArtifactsPinned,
handleWebSearchToggle,
setIsSearchDialogOpen,
handleFileSearchToggle,
handleArtifactsToggle,
handleCodeInterpreterToggle,
]);

View file

@ -17,7 +17,6 @@ import {
General,
Chat,
Speech,
Beta,
Commands,
Data,
Account,
@ -233,9 +232,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Content value={SettingsTabValues.CHAT}>
<Chat />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.BETA}>
<Beta />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}>
<Commands />
</Tabs.Content>

View file

@ -1,18 +0,0 @@
import { memo } from 'react';
import CodeArtifacts from './CodeArtifacts';
import ChatBadges from './ChatBadges';
function Beta() {
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<div className="pb-3">
<CodeArtifacts />
</div>
{/* <div className="pb-3">
<ChatBadges />
</div> */}
</div>
);
}
export default memo(Beta);

View file

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

View file

@ -1,95 +0,0 @@
import { useRecoilState } from 'recoil';
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
import { Switch } from '~/components/ui';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function CodeArtifacts() {
const [codeArtifacts, setCodeArtifacts] = useRecoilState<boolean>(store.codeArtifacts);
const [includeShadcnui, setIncludeShadcnui] = useRecoilState<boolean>(store.includeShadcnui);
const [customPromptMode, setCustomPromptMode] = useRecoilState<boolean>(store.customPromptMode);
const localize = useLocalize();
const handleCodeArtifactsChange = (value: boolean) => {
setCodeArtifacts(value);
if (!value) {
setIncludeShadcnui(false);
setCustomPromptMode(false);
}
};
const handleIncludeShadcnuiChange = (value: boolean) => {
setIncludeShadcnui(value);
};
const handleCustomPromptModeChange = (value: boolean) => {
setCustomPromptMode(value);
if (value) {
setIncludeShadcnui(false);
}
};
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{localize('com_ui_artifacts')}</h3>
<div className="space-y-2">
<SwitchItem
id="codeArtifacts"
label={localize('com_ui_artifacts_toggle')}
checked={codeArtifacts}
onCheckedChange={handleCodeArtifactsChange}
hoverCardText="com_nav_info_code_artifacts"
/>
<SwitchItem
id="includeShadcnui"
label={localize('com_ui_include_shadcnui')}
checked={includeShadcnui}
onCheckedChange={handleIncludeShadcnuiChange}
hoverCardText="com_nav_info_include_shadcnui"
disabled={!codeArtifacts || customPromptMode}
/>
<SwitchItem
id="customPromptMode"
label={localize('com_ui_custom_prompt_mode')}
checked={customPromptMode}
onCheckedChange={handleCustomPromptModeChange}
hoverCardText="com_nav_info_custom_prompt_mode"
disabled={!codeArtifacts}
/>
</div>
</div>
);
}
function SwitchItem({
id,
label,
checked,
onCheckedChange,
hoverCardText,
disabled = false,
}: {
id: string;
label: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
hoverCardText: string;
disabled?: boolean;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={disabled ? 'text-gray-400' : ''}>{label}</div>
<HoverCardSettings side="bottom" text={hoverCardText} />
</div>
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className="ml-4"
data-testid={id}
disabled={disabled}
/>
</div>
);
}

View file

@ -1,7 +1,6 @@
export { default as General } from './General/General';
export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Beta } from './Beta/Beta';
export { default as Commands } from './Commands/Commands';
export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';

View file

@ -60,7 +60,7 @@ export default function Artifacts() {
/>
<SwitchItem
id="includeShadcnui"
label={localize('com_ui_include_shadcnui_agent')}
label={localize('com_ui_include_shadcnui')}
checked={isShadcnEnabled}
onCheckedChange={handleShadcnuiChange}
hoverCardText={localize('com_nav_info_include_shadcnui')}

View file

@ -12,7 +12,10 @@ const CheckboxButton = React.forwardRef<
checked?: boolean;
defaultChecked?: boolean;
isCheckedClassName?: string;
setValue?: (values: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => void;
setValue?: (values: {
e?: React.ChangeEvent<HTMLInputElement>;
value: boolean | string;
}) => void;
}
>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => {
const checkbox = useCheckboxStore();
@ -22,7 +25,7 @@ const CheckboxButton = React.forwardRef<
if (typeof isChecked !== 'boolean') {
return;
}
setValue?.({ e, isChecked: !isChecked });
setValue?.({ e, value: !isChecked });
};
// Sync with controlled checked prop

View file

@ -25,7 +25,6 @@ import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import store, { useGetEphemeralAgent } from '~/store';
import { getArtifactsMode } from '~/utils/artifacts';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
import { useNavigate } from 'react-router-dom';
@ -68,9 +67,6 @@ export default function useChatFunctions({
const setFilesToDelete = useSetFilesToDelete();
const getEphemeralAgent = useGetEphemeralAgent();
const isTemporary = useRecoilValue(store.isTemporary);
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const includeShadcnui = useRecoilValue(store.includeShadcnui);
const customPromptMode = useRecoilValue(store.customPromptMode);
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
@ -187,10 +183,6 @@ export default function useChatFunctions({
endpointType,
overrideConvoId,
overrideUserMessageId,
artifacts:
endpoint !== EModelEndpoint.agents
? getArtifactsMode({ codeArtifacts, includeShadcnui, customPromptMode })
: undefined,
},
convo,
) as TEndpointOption;

View file

@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useCallback, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import type { VerifyToolAuthResponse } from 'librechat-data-provider';
import type { UseQueryOptions } from '@tanstack/react-query';
@ -19,9 +19,11 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
console.error(e);
}
}
return value !== undefined && value !== null && value !== '' && value !== false;
return value !== undefined && value !== null;
};
type ToolValue = boolean | string;
interface UseToolToggleOptions {
conversationId?: string | null;
toolKey: string;
@ -60,36 +62,52 @@ export function useToolToggle({
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
);
const isToolEnabled = useMemo(() => {
return ephemeralAgent?.[toolKey] ?? false;
}, [ephemeralAgent, toolKey]);
/** Track previous value to prevent infinite loops */
const prevIsToolEnabled = useRef(isToolEnabled);
const [toggleState, setToggleState] = useLocalStorage<boolean>(
// Keep localStorage in sync
const [, setLocalStorageValue] = useLocalStorage<ToolValue>(
`${localStorageKey}${key}`,
isToolEnabled,
false,
undefined,
storageCondition,
);
// The actual current value comes from ephemeralAgent
const toolValue = useMemo(() => {
return ephemeralAgent?.[toolKey] ?? false;
}, [ephemeralAgent, toolKey]);
const isToolEnabled = useMemo(() => {
// For backward compatibility, treat truthy string values as enabled
if (typeof toolValue === 'string') {
return toolValue.length > 0;
}
return toolValue === true;
}, [toolValue]);
// Sync to localStorage when ephemeralAgent changes
useEffect(() => {
const value = ephemeralAgent?.[toolKey];
if (value !== undefined) {
setLocalStorageValue(value);
}
}, [ephemeralAgent, toolKey, setLocalStorageValue]);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);
const handleChange = useCallback(
({ e, isChecked }: { e?: React.ChangeEvent<HTMLInputElement>; isChecked: boolean }) => {
({ e, value }: { e?: React.ChangeEvent<HTMLInputElement>; value: ToolValue }) => {
if (isAuthenticated !== undefined && !isAuthenticated && setIsDialogOpen) {
setIsDialogOpen(true);
e?.preventDefault?.();
return;
}
setToggleState(isChecked);
// Update ephemeralAgent (localStorage will sync automatically via effect)
setEphemeralAgent((prev) => ({
...prev,
[toolKey]: isChecked,
...(prev || {}),
[toolKey]: value,
}));
},
[setToggleState, setIsDialogOpen, isAuthenticated, setEphemeralAgent, toolKey],
[setIsDialogOpen, isAuthenticated, setEphemeralAgent, toolKey],
);
const debouncedChange = useMemo(
@ -97,18 +115,12 @@ export function useToolToggle({
[handleChange],
);
useEffect(() => {
if (prevIsToolEnabled.current !== isToolEnabled) {
setToggleState(isToolEnabled);
}
prevIsToolEnabled.current = isToolEnabled;
}, [isToolEnabled, setToggleState]);
return {
toggleState,
toggleState: toolValue, // Return the actual value from ephemeralAgent
handleChange,
isToolEnabled,
setToggleState,
toolValue,
setToggleState: (value: ToolValue) => handleChange({ value }), // Adapter for direct setting
ephemeralAgent,
debouncedChange,
setEphemeralAgent,

View file

@ -68,7 +68,7 @@ const createErrorMessage = ({
errorMetadata?: Partial<TMessage>;
submission: EventSubmission;
error?: Error | unknown;
}) => {
}): TMessage => {
const currentMessages = getMessages();
const latestMessage = currentMessages?.[currentMessages.length - 1];
let errorMessage: TMessage;
@ -123,7 +123,7 @@ const createErrorMessage = ({
error: true,
};
}
return tMessageSchema.parse(errorMessage);
return tMessageSchema.parse(errorMessage) as TMessage;
};
export const getConvoTitle = ({
@ -374,9 +374,6 @@ export default function useEventHandlers({
});
let update = {} as TConversation;
if (conversationId) {
applyAgentTemplate(conversationId, submission.conversation.conversationId);
}
if (setConversation && !isAddedRequest) {
setConversation((prevState) => {
const parentId = isRegenerate ? userMessage.overrideParentMessageId : parentMessageId;
@ -411,6 +408,14 @@ export default function useEventHandlers({
});
}
if (conversationId) {
applyAgentTemplate(
conversationId,
submission.conversation.conversationId,
submission.ephemeralAgent,
);
}
if (resetLatestMessage) {
resetLatestMessage();
}
@ -513,6 +518,15 @@ export default function useEventHandlers({
}
return update;
});
if (conversation.conversationId && submission.ephemeralAgent) {
applyAgentTemplate(
conversation.conversationId,
submissionConvo.conversationId,
submission.ephemeralAgent,
);
}
if (location.pathname === '/c/new') {
navigate(`/c/${conversation.conversationId}`, { replace: true });
}
@ -521,18 +535,19 @@ export default function useEventHandlers({
setIsSubmitting(false);
},
[
setShowStopButton,
setCompleted,
getMessages,
announcePolite,
navigate,
genTitle,
setConversation,
isAddedRequest,
setIsSubmitting,
getMessages,
setMessages,
queryClient,
setCompleted,
isAddedRequest,
announcePolite,
setConversation,
setIsSubmitting,
setShowStopButton,
location.pathname,
navigate,
applyAgentTemplate,
],
);
@ -550,7 +565,7 @@ export default function useEventHandlers({
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, convoId], finalMessages);
};
const parseErrorResponse = (data: TResData | Partial<TMessage>) => {
const parseErrorResponse = (data: TResData | Partial<TMessage>): TMessage => {
const metadata = data['responseMessage'] ?? data;
const errorMessage: Partial<TMessage> = {
...initialResponse,
@ -563,7 +578,7 @@ export default function useEventHandlers({
errorMessage.messageId = v4();
}
return tMessageSchema.parse(errorMessage);
return tMessageSchema.parse(errorMessage) as TMessage;
};
if (!data) {
@ -613,7 +628,7 @@ export default function useEventHandlers({
...data,
error: true,
parentMessageId: userMessage.messageId,
});
}) as TMessage;
setErrorMessages(receivedConvoId, errorResponse);
if (receivedConvoId && paramId === Constants.NEW_CONVO && newConversation) {

View file

@ -367,7 +367,6 @@
"com_nav_delete_cache_storage": "Delete TTS cache storage",
"com_nav_delete_data_info": "All your data will be deleted.",
"com_nav_delete_warning": "WARNING: This will permanently delete your account.",
"com_nav_edit_chat_badges": "Edit Chat Badges",
"com_nav_enable_cache_tts": "Enable cache TTS",
"com_nav_enable_cloud_browser_voice": "Use cloud-based voices",
"com_nav_enabled": "Enabled",
@ -578,6 +577,7 @@
"com_ui_artifacts": "Artifacts",
"com_ui_artifacts_toggle": "Toggle Artifacts UI",
"com_ui_artifacts_toggle_agent": "Enable Artifacts",
"com_ui_artifacts_options": "Artifacts Options",
"com_ui_ascending": "Asc",
"com_ui_assistant": "Assistant",
"com_ui_assistant_delete_error": "There was an error deleting the assistant",
@ -819,8 +819,7 @@
"com_ui_import_conversation_file_type_error": "Unsupported import type",
"com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully",
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
"com_ui_include_shadcnui_agent": "Include shadcn/ui instructions",
"com_ui_include_shadcnui": "Include shadcn/ui",
"com_ui_input": "Input",
"com_ui_instructions": "Instructions",
"com_ui_key": "Key",
@ -1075,4 +1074,4 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You"
}
}

View file

@ -23,7 +23,11 @@ export const ephemeralAgentByConvoId = atomFamily<TEphemeralAgent | null, string
export function useApplyNewAgentTemplate() {
const applyTemplate = useRecoilCallback(
({ snapshot, set }) =>
async (targetId: string, _sourceId: string | null = Constants.NEW_CONVO) => {
async (
targetId: string,
_sourceId: string | null = Constants.NEW_CONVO,
ephemeralAgentState?: TEphemeralAgent | null,
) => {
const sourceId = _sourceId || Constants.NEW_CONVO;
logger.log('agents', `Attempting to apply template from "${sourceId}" to "${targetId}"`);
@ -35,7 +39,8 @@ export function useApplyNewAgentTemplate() {
try {
// 1. Get the current agent state from the "new" conversation template using snapshot
// getPromise reads the value without subscribing
const agentTemplate = await snapshot.getPromise(ephemeralAgentByConvoId(sourceId));
const agentTemplate =
ephemeralAgentState ?? (await snapshot.getPromise(ephemeralAgentByConvoId(sourceId)));
// 2. Check if a template state actually exists
if (agentTemplate) {

View file

@ -43,9 +43,6 @@ const localStorageAtoms = {
// Beta features settings
modularChat: atomWithLocalStorage('modularChat', true),
LaTeXParsing: atomWithLocalStorage('LaTeXParsing', true),
codeArtifacts: atomWithLocalStorage('codeArtifacts', false),
includeShadcnui: atomWithLocalStorage('includeShadcnui', false),
customPromptMode: atomWithLocalStorage('customPromptMode', false),
centerFormOnLanding: atomWithLocalStorage('centerFormOnLanding', true),
showFooter: atomWithLocalStorage('showFooter', true),

View file

@ -1,29 +1,10 @@
import dedent from 'dedent';
import { ArtifactModes, shadcnComponents } from 'librechat-data-provider';
import { shadcnComponents } from 'librechat-data-provider';
import type {
SandpackProviderProps,
SandpackPredefinedTemplate,
} from '@codesandbox/sandpack-react';
export const getArtifactsMode = ({
codeArtifacts,
includeShadcnui,
customPromptMode,
}: {
codeArtifacts: boolean;
includeShadcnui: boolean;
customPromptMode: boolean;
}): ArtifactModes | undefined => {
if (!codeArtifacts) {
return undefined;
} else if (customPromptMode) {
return ArtifactModes.CUSTOM;
} else if (includeShadcnui) {
return ArtifactModes.SHADCNUI;
}
return ArtifactModes.DEFAULT;
};
const artifactFilename = {
'application/vnd.mermaid': 'App.tsx',
'application/vnd.react': 'App.tsx',

View file

@ -1459,6 +1459,8 @@ export enum LocalStorageKeys {
LAST_WEB_SEARCH_TOGGLE_ = 'LAST_WEB_SEARCH_TOGGLE_',
/** Last checked toggle for File Search per conversation ID */
LAST_FILE_SEARCH_TOGGLE_ = 'LAST_FILE_SEARCH_TOGGLE_',
/** Last checked toggle for Artifacts per conversation ID */
LAST_ARTIFACTS_TOGGLE_ = 'LAST_ARTIFACTS_TOGGLE_',
/** Key for the last selected agent provider */
LAST_AGENT_PROVIDER = 'lastAgentProvider',
/** Key for the last selected agent model */

View file

@ -117,7 +117,6 @@ export type TPayload = Partial<TMessage> &
};
export type TSubmission = {
artifacts?: string;
plugin?: TResPlugin;
plugins?: TResPlugin[];
userMessage: TMessage;