LibreChat/client/src/store/families.ts
Danny Avila 439bc98682
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
⏸ refactor: Improve UX for Parallel Streams (Multi-Convo) (#11096)
* 🌊 feat: Implement multi-conversation feature with added conversation context and payload adjustments

* refactor: Replace isSubmittingFamily with isSubmitting across message components for consistency

* feat: Add loadAddedAgent and processAddedConvo for multi-conversation agent execution

* refactor: Update ContentRender usage to conditionally render PlaceholderRow based on isLast and isSubmitting

* WIP: first pass, sibling index

* feat: Enhance multi-conversation support with agent tracking and display improvements

* refactor: Introduce isEphemeralAgentId utility and update related logic for agent handling

* refactor: Implement createDualMessageContent utility for sibling message display and enhance useStepHandler for added conversations

* refactor: duplicate tools for added agent if ephemeral and primary agent is also ephemeral

* chore: remove deprecated multimessage rendering

* refactor: enhance dual message content creation and agent handling for parallel rendering

* refactor: streamline message rendering and submission handling by removing unused state and optimizing conditional logic

* refactor: adjust content handling in parallel mode to utilize existing content for improved agent display

* refactor: update @librechat/agents dependency to version 3.0.53

* refactor: update @langchain/core and @librechat/agents dependencies to latest versions

* refactor: remove deprecated @langchain/core dependency from package.json

* chore: remove unused SearchToolConfig and GetSourcesParams types from web.ts

* refactor: remove unused message properties from Message component

* refactor: enhance parallel content handling with groupId support in ContentParts and useStepHandler

* refactor: implement parallel content styling in Message, MessageRender, and ContentRender components. use explicit model name

* refactor: improve agent ID handling in createDualMessageContent for dual message display

* refactor: simplify title generation in AddedConvo by removing unused sender and preset logic

* refactor: replace string interpolation with cn utility for className in HoverButtons component

* refactor: enhance agent ID handling by adding suffix management for parallel agents and updating related components

* refactor: enhance column ordering in ContentParts by sorting agents with suffix management

* refactor: update @librechat/agents dependency to version 3.0.55

* feat: implement parallel content rendering with metadata support

- Added `ParallelContentRenderer` and `ParallelColumns` components for rendering messages in parallel based on groupId and agentId.
- Introduced `contentMetadataMap` to store metadata for each content part, allowing efficient parallel content detection.
- Updated `Message` and `ContentRender` components to utilize the new metadata structure for rendering.
- Modified `useStepHandler` to manage content indices and metadata during message processing.
- Enhanced `IJobStore` interface and its implementations to support storing and retrieving content metadata.
- Updated data schemas to include `contentMetadataMap` for messages, enabling multi-agent and parallel execution scenarios.

* refactor: update @librechat/agents dependency to version 3.0.56

* refactor: remove unused EPHEMERAL_AGENT_ID constant and simplify agent ID check

* refactor: enhance multi-agent message processing and primary agent determination

* refactor: implement branch message functionality for parallel responses

* refactor: integrate added conversation retrieval into message editing and regeneration processes

* refactor: remove unused isCard and isMultiMessage props from MessageRender and ContentRender components

* refactor: update @librechat/agents dependency to version 3.0.60

* refactor: replace usage of EPHEMERAL_AGENT_ID constant with isEphemeralAgentId function for improved clarity and consistency

* refactor: standardize agent ID format in tests for consistency

* chore: move addedConvo property to the correct position in payload construction

* refactor: rename agent_id values in loadAgent tests for clarity

* chore: reorder props in ContentParts component for improved readability

* refactor: rename variable 'content' to 'result' for clarity in RedisJobStore tests

* refactor: streamline useMessageActions by removing duplicate handleFeedback assignment

* chore: revert placeholder rendering logic MessageRender and ContentRender components to original

* refactor: implement useContentMetadata hook for optimized content metadata handling

* refactor: remove contentMetadataMap and related logic from the codebase and revert back to agentId/groupId in content parts

- Eliminated contentMetadataMap from various components and services, simplifying the handling of message content.
- Updated functions to directly access agentId and groupId from content parts instead of relying on a separate metadata map.
- Adjusted related hooks and components to reflect the removal of contentMetadataMap, ensuring consistent handling of message content.
- Updated tests and documentation to align with the new structure of message content handling.

* refactor: remove logging from groupParallelContent function to clean up output

* refactor: remove model parameter from TBranchMessageRequest type for simplification

* refactor: enhance branch message creation by stripping metadata for standalone content

* chore: streamline branch message creation by simplifying content filtering and removing unnecessary metadata checks

* refactor: include attachments in branch message creation for improved content handling

* refactor: streamline agent content processing by consolidating primary agent identification and filtering logic

* refactor: simplify multi-agent message processing by creating a dedicated mapping method and enhancing content filtering

* refactor: remove unused parameter from loadEphemeralAgent function for cleaner code

* refactor: update groupId handling in metadata to only set when provided by the server
2025-12-25 01:43:54 -05:00

429 lines
12 KiB
TypeScript

import { useEffect } from 'react';
import { createSearchParams } from 'react-router-dom';
import {
atom,
selector,
atomFamily,
DefaultValue,
selectorFamily,
useRecoilState,
useRecoilValue,
useSetRecoilState,
useRecoilCallback,
} from 'recoil';
import { LocalStorageKeys, isEphemeralAgentId, Constants } from 'librechat-data-provider';
import type { TMessage, TPreset, TConversation, TSubmission } from 'librechat-data-provider';
import type { TOptionSettings, ExtendedFile } from '~/common';
import {
clearModelForNonEphemeralAgent,
createChatSearchParams,
storeEndpointSettings,
logger,
} from '~/utils';
import { useSetConvoContext } from '~/Providers/SetConvoContext';
const latestMessageKeysAtom = atom<(string | number)[]>({
key: 'latestMessageKeys',
default: [],
});
const submissionKeysAtom = atom<(string | number)[]>({
key: 'submissionKeys',
default: [],
});
const latestMessageFamily = atomFamily<TMessage | null, string | number | null>({
key: 'latestMessageByIndex',
default: null,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]);
logger.log('Recoil Effect: Setting latestMessage', { key, newValue });
});
},
] as const,
});
const submissionByIndex = atomFamily<TSubmission | null, string | number>({
key: 'submissionByIndex',
default: null,
});
const latestMessageKeysSelector = selector<(string | number)[]>({
key: 'latestMessageKeysSelector',
get: ({ get }) => {
const keys = get(conversationKeysAtom);
return keys.filter((key) => get(latestMessageFamily(key)) !== null);
},
set: ({ set }, newKeys) => {
logger.log('setting latestMessageKeys', { newKeys });
set(latestMessageKeysAtom, newKeys);
},
});
const submissionKeysSelector = selector<(string | number)[]>({
key: 'submissionKeysSelector',
get: ({ get }) => {
const keys = get(conversationKeysAtom);
return keys.filter((key) => get(submissionByIndex(key)) !== null);
},
set: ({ set }, newKeys) => {
logger.log('setting submissionKeysAtom', newKeys);
set(submissionKeysAtom, newKeys);
},
});
const conversationByIndex = atomFamily<TConversation | null, string | number>({
key: 'conversationByIndex',
default: null,
effects: [
({ onSet, node }) => {
onSet(async (newValue, oldValue) => {
const index = Number(node.key.split('__')[1]);
logger.log('conversation', 'Setting conversation:', { index, newValue, oldValue });
if (newValue?.assistant_id != null && newValue.assistant_id) {
localStorage.setItem(
`${LocalStorageKeys.ASST_ID_PREFIX}${index}${newValue.endpoint}`,
newValue.assistant_id,
);
}
if (newValue?.agent_id != null && !isEphemeralAgentId(newValue.agent_id)) {
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}${index}`, newValue.agent_id);
}
if (newValue?.spec != null && newValue.spec) {
localStorage.setItem(LocalStorageKeys.LAST_SPEC, newValue.spec);
}
if (newValue?.tools && Array.isArray(newValue.tools)) {
localStorage.setItem(
LocalStorageKeys.LAST_TOOLS,
JSON.stringify(newValue.tools.filter((el) => !!el)),
);
}
if (!newValue) {
return;
}
storeEndpointSettings(newValue);
const convoToStore = { ...newValue };
clearModelForNonEphemeralAgent(convoToStore);
localStorage.setItem(
`${LocalStorageKeys.LAST_CONVO_SETUP}_${index}`,
JSON.stringify(convoToStore),
);
const disableParams = newValue.disableParams === true;
const shouldUpdateParams =
index === 0 &&
!disableParams &&
newValue.createdAt === '' &&
JSON.stringify(newValue) !== JSON.stringify(oldValue) &&
(oldValue as TConversation)?.conversationId === Constants.NEW_CONVO;
if (shouldUpdateParams) {
const newParams = createChatSearchParams(newValue);
const searchParams = createSearchParams(newParams);
const url = `${window.location.pathname}?${searchParams.toString()}`;
window.history.pushState({}, '', url);
}
});
},
] as const,
});
const filesByIndex = atomFamily<Map<string, ExtendedFile>, string | number>({
key: 'filesByIndex',
default: new Map(),
});
const conversationKeysAtom = atom<(string | number)[]>({
key: 'conversationKeys',
default: [],
});
const allConversationsSelector = selector({
key: 'allConversationsSelector',
get: ({ get }) => {
const keys = get(conversationKeysAtom);
return keys.map((key) => get(conversationByIndex(key))).map((convo) => convo?.conversationId);
},
});
const presetByIndex = atomFamily<TPreset | null, string | number>({
key: 'presetByIndex',
default: null,
});
const textByIndex = atomFamily<string, string | number>({
key: 'textByIndex',
default: '',
});
const showStopButtonByIndex = atomFamily<boolean, string | number>({
key: 'showStopButtonByIndex',
default: false,
});
const abortScrollFamily = atomFamily<boolean, string | number>({
key: 'abortScrollByIndex',
default: false,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]);
logger.log('message_scrolling', 'Recoil Effect: Setting abortScrollByIndex', {
key,
newValue,
});
});
},
] as const,
});
const isSubmittingFamily = atomFamily({
key: 'isSubmittingByIndex',
default: false,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
const key = Number(node.key.split(Constants.COMMON_DIVIDER)[1]);
logger.log('message_stream', 'Recoil Effect: Setting isSubmittingByIndex', {
key,
newValue,
});
});
},
],
});
const anySubmittingSelector = selector<boolean>({
key: 'anySubmittingSelector',
get: ({ get }) => {
const keys = get(conversationKeysAtom);
return keys.some((key) => get(isSubmittingFamily(key)) === true);
},
});
const optionSettingsFamily = atomFamily<TOptionSettings, string | number>({
key: 'optionSettingsByIndex',
default: {},
});
const showPopoverFamily = atomFamily({
key: 'showPopoverByIndex',
default: false,
});
const activePromptByIndex = atomFamily<string | undefined, string | number | null>({
key: 'activePromptByIndex',
default: undefined,
});
const showMentionPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showMentionPopoverByIndex',
default: false,
});
const showPlusPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showPlusPopoverByIndex',
default: false,
});
const showPromptsPopoverFamily = atomFamily<boolean, string | number | null>({
key: 'showPromptsPopoverByIndex',
default: false,
});
const globalAudioURLFamily = atomFamily<string | null, string | number | null>({
key: 'globalAudioURLByIndex',
default: null,
});
const globalAudioFetchingFamily = atomFamily<boolean, string | number | null>({
key: 'globalAudioisFetchingByIndex',
default: false,
});
const globalAudioPlayingFamily = atomFamily<boolean, string | number | null>({
key: 'globalAudioisPlayingByIndex',
default: false,
});
const activeRunFamily = atomFamily<string | null, string | number | null>({
key: 'activeRunByIndex',
default: null,
});
const audioRunFamily = atomFamily<string | null, string | number | null>({
key: 'audioRunByIndex',
default: null,
});
const messagesSiblingIdxFamily = atomFamily<number, string | null | undefined>({
key: 'messagesSiblingIdx',
default: 0,
});
function useCreateConversationAtom(key: string | number) {
const hasSetConversation = useSetConvoContext();
const [keys, setKeys] = useRecoilState(conversationKeysAtom);
const setConversation = useSetRecoilState(conversationByIndex(key));
const conversation = useRecoilValue(conversationByIndex(key));
useEffect(() => {
if (!keys.includes(key)) {
setKeys([...keys, key]);
}
}, [key, keys, setKeys]);
return { hasSetConversation, conversation, setConversation };
}
function useClearConvoState() {
/** Clears all active conversations. Pass `true` to skip the first or root conversation */
const clearAllConversations = useRecoilCallback(
({ reset, snapshot }) =>
async (skipFirst?: boolean) => {
const conversationKeys = await snapshot.getPromise(conversationKeysAtom);
for (const conversationKey of conversationKeys) {
if (skipFirst === true && conversationKey == 0) {
continue;
}
reset(conversationByIndex(conversationKey));
const conversation = await snapshot.getPromise(conversationByIndex(conversationKey));
if (conversation) {
reset(latestMessageFamily(conversationKey));
}
}
reset(conversationKeysAtom);
},
[],
);
return clearAllConversations;
}
const conversationByKeySelector = selectorFamily({
key: 'conversationByKeySelector',
get:
(index: string | number) =>
({ get }) => {
const conversation = get(conversationByIndex(index));
return conversation;
},
});
function useClearSubmissionState() {
const clearAllSubmissions = useRecoilCallback(
({ reset, set, snapshot }) =>
async (skipFirst?: boolean) => {
const submissionKeys = await snapshot.getPromise(submissionKeysSelector);
logger.log('submissionKeys', submissionKeys);
for (const key of submissionKeys) {
if (skipFirst === true && key == 0) {
continue;
}
logger.log('resetting submission', key);
reset(submissionByIndex(key));
}
set(submissionKeysSelector, []);
},
[],
);
return clearAllSubmissions;
}
function useClearLatestMessages(context?: string) {
const clearAllLatestMessages = useRecoilCallback(
({ reset, set, snapshot }) =>
async (skipFirst?: boolean) => {
const latestMessageKeys = await snapshot.getPromise(latestMessageKeysSelector);
logger.log('[clearAllLatestMessages] latestMessageKeys', latestMessageKeys);
if (context != null && context) {
logger.log(`[clearAllLatestMessages] context: ${context}`);
}
for (const key of latestMessageKeys) {
if (skipFirst === true && key == 0) {
continue;
}
logger.log(`[clearAllLatestMessages] resetting latest message; key: ${key}`);
reset(latestMessageFamily(key));
}
set(latestMessageKeysSelector, []);
},
[],
);
return clearAllLatestMessages;
}
const updateConversationSelector = selectorFamily({
key: 'updateConversationSelector',
get: () => () => null as Partial<TConversation> | null,
set:
(conversationId: string) =>
({ set, get }, newPartialConversation) => {
if (newPartialConversation instanceof DefaultValue) {
return;
}
const keys = get(conversationKeysAtom);
keys.forEach((key) => {
set(conversationByIndex(key), (prevConversation) => {
if (prevConversation && prevConversation.conversationId === conversationId) {
return {
...prevConversation,
...newPartialConversation,
};
}
return prevConversation;
});
});
},
});
export default {
conversationKeysAtom,
conversationByIndex,
filesByIndex,
presetByIndex,
submissionByIndex,
textByIndex,
showStopButtonByIndex,
abortScrollFamily,
isSubmittingFamily,
optionSettingsFamily,
showPopoverFamily,
latestMessageFamily,
messagesSiblingIdxFamily,
anySubmittingSelector,
allConversationsSelector,
conversationByKeySelector,
useClearConvoState,
useCreateConversationAtom,
showMentionPopoverFamily,
globalAudioURLFamily,
activeRunFamily,
audioRunFamily,
globalAudioPlayingFamily,
globalAudioFetchingFamily,
showPlusPopoverFamily,
activePromptByIndex,
useClearSubmissionState,
useClearLatestMessages,
showPromptsPopoverFamily,
updateConversationSelector,
};