🪄 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

@ -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) {