diff --git a/apps/chat/src/components/Chatbar/ChatFolders.tsx b/apps/chat/src/components/Chatbar/ChatFolders.tsx index f4a4764f7d..0f434f3bf9 100644 --- a/apps/chat/src/components/Chatbar/ChatFolders.tsx +++ b/apps/chat/src/components/Chatbar/ChatFolders.tsx @@ -60,6 +60,10 @@ const ChatFolderTemplate = ({ searchTerm, ), ); + const allConversations = useAppSelector( + ConversationsSelectors.selectConversations, + ); + const allFolders = useAppSelector(ConversationsSelectors.selectFolders); const conversationFolders = useAppSelector((state) => ConversationsSelectors.selectFilteredFolders( state, @@ -116,7 +120,6 @@ const ChatFolderTemplate = ({ }, [dispatch], ); - const onDropBetweenFolders = useCallback( (folder: FolderInterface, parentFolderId: string | undefined) => { dispatch( @@ -152,7 +155,9 @@ const ChatFolderTemplate = ({ currentFolder={folder} itemComponent={ConversationComponent} allItems={conversations} + allItemsWithoutFilters={allConversations} allFolders={conversationFolders} + allFoldersWithoutFilters={allFolders} highlightedFolders={highlightedFolders} openedFoldersIds={openedFoldersIds} handleDrop={handleDrop} diff --git a/apps/chat/src/components/Chatbar/Conversation.tsx b/apps/chat/src/components/Chatbar/Conversation.tsx index 68d288256d..11ec918ed0 100644 --- a/apps/chat/src/components/Chatbar/Conversation.tsx +++ b/apps/chat/src/components/Chatbar/Conversation.tsx @@ -11,8 +11,14 @@ import { useState, } from 'react'; +import { useTranslation } from 'next-i18next'; + import classNames from 'classnames'; +import { + isEntityNameOnSameLevelUnique, + prepareEntityName, +} from '@/src/utils/app/common'; import { notAllowedSymbolsRegex } from '@/src/utils/app/file'; import { hasParentWithFloatingOverlay } from '@/src/utils/app/modals'; import { MoveType, getDragImage } from '@/src/utils/app/move'; @@ -22,6 +28,7 @@ import { isEntityOrParentsExternal } from '@/src/utils/app/share'; import { Conversation, ConversationInfo } from '@/src/types/chat'; import { FeatureType, isNotLoaded } from '@/src/types/common'; import { SharingType } from '@/src/types/share'; +import { Translation } from '@/src/types/translation'; import { ConversationsActions, @@ -96,6 +103,8 @@ interface Props { } export const ConversationComponent = ({ item: conversation, level }: Props) => { + const { t } = useTranslation(Translation.Chat); + const dispatch = useAppDispatch(); const modelsMap = useAppSelector(ModelsSelectors.selectModelsMap); @@ -141,6 +150,9 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { const newFolderName = useAppSelector((state) => ConversationsSelectors.selectNewFolderName(state, conversation.folderId), ); + const allConversations = useAppSelector( + ConversationsSelectors.selectConversations, + ); const { refs, context } = useFloating({ open: isContextMenu, @@ -166,22 +178,46 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { const handleRename = useCallback( (conversation: ConversationInfo) => { - if (renameValue.trim().length > 0) { + const newName = prepareEntityName(renameValue); + setRenameValue(newName); + + if ( + !isEntityNameOnSameLevelUnique(newName, conversation, allConversations) + ) { + dispatch( + UIActions.showToast({ + message: t( + 'Conversation with name "{{newName}}" already exists in this folder.', + { + ns: 'chat', + newName, + }, + ), + type: 'error', + }), + ); + + return; + } + + if (newName.length > 0) { dispatch( ConversationsActions.updateConversation({ id: conversation.id, values: { - name: renameValue.trim(), + name: newName, isNameChanged: true, }, }), ); + setRenameValue(''); - setIsRenaming(false); setIsContextMenu(false); } + + setIsRenaming(false); }, - [dispatch, renameValue], + [allConversations, dispatch, renameValue, t], ); const handleEnterDown = useCallback( @@ -217,11 +253,10 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { conversationIds: [conversation.id], }), ); + setIsDeleting(false); } else if (isRenaming) { handleRename(conversation); } - setIsDeleting(false); - setIsRenaming(false); }, [conversation, dispatch, handleRename, isDeleting, isRenaming], ); diff --git a/apps/chat/src/components/Folder/Folder.tsx b/apps/chat/src/components/Folder/Folder.tsx index bfef62fb97..4f24bc4ead 100644 --- a/apps/chat/src/components/Folder/Folder.tsx +++ b/apps/chat/src/components/Folder/Folder.tsx @@ -19,6 +19,10 @@ import { useTranslation } from 'next-i18next'; import classNames from 'classnames'; +import { + isEntityNameOnSameLevelUnique, + prepareEntityName, +} from '@/src/utils/app/common'; import { notAllowedSymbolsRegex } from '@/src/utils/app/file'; import { compareEntitiesByName, @@ -28,6 +32,7 @@ import { import { hasParentWithFloatingOverlay } from '@/src/utils/app/modals'; import { getDragImage, + getEntityMoveType, getFolderMoveType, hasDragEventAnyData, } from '@/src/utils/app/move'; @@ -90,6 +95,8 @@ export interface FolderProps { maxDepth?: number; highlightTemporaryFolders?: boolean; withBorderHighlight?: boolean; + allFoldersWithoutFilters?: FolderInterface[]; + allItemsWithoutFilters?: T[]; } const Folder = ({ @@ -97,7 +104,9 @@ const Folder = ({ searchTerm, itemComponent, allItems, + allItemsWithoutFilters = [], allFolders, + allFoldersWithoutFilters = [], highlightedFolders, openedFoldersIds, level = 0, @@ -229,11 +238,45 @@ const Folder = ({ if (!onRenameFolder) { return; } - renameValue.trim() && onRenameFolder(renameValue, currentFolder.id); + + const newName = prepareEntityName(renameValue); + setRenameValue(newName); + + if ( + !isEntityNameOnSameLevelUnique( + newName, + currentFolder, + allFoldersWithoutFilters, + ) + ) { + dispatch( + UIActions.showToast({ + message: t( + 'Folder with name "{{folderName}}" already exists in this folder.', + { + ns: 'folder', + folderName: newName, + }, + ), + type: 'error', + }), + ); + + return; + } + + newName && onRenameFolder(newName, currentFolder.id); setRenameValue(''); setIsRenaming(false); setIsContextMenu(false); - }, [onRenameFolder, renameValue, currentFolder]); + }, [ + onRenameFolder, + renameValue, + currentFolder, + allFoldersWithoutFilters, + dispatch, + t, + ]); const handleEnterDown = useCallback( (e: KeyboardEvent) => { @@ -297,19 +340,79 @@ const Folder = ({ ); return; } + + if ( + !isEntityNameOnSameLevelUnique( + draggedFolder.name, + { ...draggedFolder, folderId: currentFolder.id }, + allFoldersWithoutFilters, + ) + ) { + dispatch( + UIActions.showToast({ + message: t( + 'Folder with name "{{folderName}}" already exists in this folder.', + { + ns: 'folder', + folderName: draggedFolder.name, + }, + ), + type: 'error', + }), + ); + + return; + } } + + const entityData = e.dataTransfer.getData( + getEntityMoveType(featureType), + ); + if (entityData) { + const draggedEntity = JSON.parse(entityData); + + if ( + !isEntityNameOnSameLevelUnique( + draggedEntity.name, + { ...draggedEntity, folderId: currentFolder.id }, + allItemsWithoutFilters, + ) + ) { + dispatch( + UIActions.showToast({ + message: t( + '{{entityType}} with name "{{entityName}}" already exists in this folder.', + { + ns: 'common', + entityType: + featureType === FeatureType.Chat + ? 'Conversation' + : 'Prompt', + entityName: draggedEntity.name, + }, + ), + type: 'error', + }), + ); + + return; + } + } + handleDrop(e, currentFolder); } }, [ - handleDrop, - isExternal, - dispatch, + allFolders, + allFoldersWithoutFilters, + allItemsWithoutFilters, currentFolder, + dispatch, featureType, - allFolders, - maxDepth, + handleDrop, + isExternal, level, + maxDepth, t, ], ); @@ -680,7 +783,9 @@ const Folder = ({ currentFolder={item} itemComponent={itemComponent} allItems={allItems} + allItemsWithoutFilters={allItemsWithoutFilters} allFolders={allFolders} + allFoldersWithoutFilters={allFoldersWithoutFilters} highlightedFolders={highlightedFolders} openedFoldersIds={openedFoldersIds} loadingFolderIds={loadingFolderIds} diff --git a/apps/chat/src/components/Promptbar/components/PromptFolders.tsx b/apps/chat/src/components/Promptbar/components/PromptFolders.tsx index 3d9a1dc294..8d778626a3 100644 --- a/apps/chat/src/components/Promptbar/components/PromptFolders.tsx +++ b/apps/chat/src/components/Promptbar/components/PromptFolders.tsx @@ -54,9 +54,11 @@ const PromptFolderTemplate = ({ const highlightedFolders = useAppSelector( PromptsSelectors.selectSelectedPromptFoldersIds, ); + const allPrompts = useAppSelector(PromptsSelectors.selectPrompts); const prompts = useAppSelector((state) => PromptsSelectors.selectFilteredPrompts(state, filters, searchTerm), ); + const allFolders = useAppSelector(PromptsSelectors.selectFolders); const promptFolders = useAppSelector((state) => PromptsSelectors.selectFilteredFolders( state, @@ -147,7 +149,9 @@ const PromptFolderTemplate = ({ currentFolder={folder} itemComponent={PromptComponent} allItems={prompts} + allItemsWithoutFilters={allPrompts} allFolders={promptFolders} + allFoldersWithoutFilters={allFolders} highlightedFolders={highlightedFolders} openedFoldersIds={openedFoldersIds} handleDrop={handleDrop} diff --git a/apps/chat/src/components/Promptbar/components/PromptModal.tsx b/apps/chat/src/components/Promptbar/components/PromptModal.tsx index f0c7cf72d1..d37dbb9e04 100644 --- a/apps/chat/src/components/Promptbar/components/PromptModal.tsx +++ b/apps/chat/src/components/Promptbar/components/PromptModal.tsx @@ -13,14 +13,19 @@ import { useTranslation } from 'next-i18next'; import classNames from 'classnames'; +import { + isEntityNameOnSameLevelUnique, + prepareEntityName, +} from '@/src/utils/app/common'; import { notAllowedSymbolsRegex } from '@/src/utils/app/file'; import { onBlur } from '@/src/utils/app/style-helpers'; import { Prompt } from '@/src/types/prompt'; import { Translation } from '@/src/types/translation'; -import { useAppSelector } from '@/src/store/hooks'; +import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; import { PromptsSelectors } from '@/src/store/prompts/prompts.reducers'; +import { UIActions } from '@/src/store/ui/ui.reducers'; import { NotFoundEntity } from '@/src/components/Common/NotFoundEntity'; @@ -35,8 +40,11 @@ interface Props { } export const PromptModal: FC = ({ isOpen, onClose, onUpdatePrompt }) => { + const dispatch = useAppDispatch(); + const selectedPrompt = useAppSelector(PromptsSelectors.selectSelectedPrompt); const isLoading = useAppSelector(PromptsSelectors.isPromptLoading); + const allPrompts = useAppSelector(PromptsSelectors.selectPrompts); const { t } = useTranslation(Translation.PromptBar); const [name, setName] = useState(''); @@ -68,44 +76,70 @@ export const PromptModal: FC = ({ isOpen, onClose, onUpdatePrompt }) => { setContent(e.target.value); }; - const handleSubmit = useCallback( - (e: MouseEvent, selectedPrompt: Prompt) => { - e.preventDefault(); - e.stopPropagation(); + const updatePrompt = useCallback( + (selectedPrompt: Prompt) => { + const newName = prepareEntityName(name); + setName(newName); - setSubmitted(true); + if (!newName) return; + + if (!isEntityNameOnSameLevelUnique(newName, selectedPrompt, allPrompts)) { + dispatch( + UIActions.showToast({ + message: t( + 'Prompt with name "{{newName}}" already exists in this folder.', + { + ns: 'prompt', + newName, + }, + ), + type: 'error', + }), + ); - if (!name || name.trim() === '') { return; } - const updatedPrompt = { + + onUpdatePrompt({ ...selectedPrompt, - name: name.trim(), + name: newName, description: description?.trim(), content: content.trim(), - }; - - onUpdatePrompt(updatedPrompt); + }); setSubmitted(false); onClose(); }, - [name, description, content, onUpdatePrompt, onClose], + [ + allPrompts, + content, + description, + dispatch, + name, + onClose, + onUpdatePrompt, + t, + ], + ); + + const handleSubmit = useCallback( + (e: MouseEvent, selectedPrompt: Prompt) => { + e.preventDefault(); + e.stopPropagation(); + + setSubmitted(true); + + updatePrompt(selectedPrompt); + }, + [updatePrompt], ); const handleEnter = useCallback( (e: KeyboardEvent, selectedPrompt: Prompt) => { if (e.key === 'Enter' && !e.shiftKey) { - onUpdatePrompt({ - ...selectedPrompt, - name, - description, - content: content.trim(), - }); - setSubmitted(false); - onClose(); + updatePrompt(selectedPrompt); } }, - [onUpdatePrompt, name, description, content, onClose], + [updatePrompt], ); useEffect(() => { diff --git a/apps/chat/src/constants/default-settings.ts b/apps/chat/src/constants/default-settings.ts index 73c58dd2c6..2e7485c67d 100644 --- a/apps/chat/src/constants/default-settings.ts +++ b/apps/chat/src/constants/default-settings.ts @@ -8,6 +8,7 @@ export const DIAL_API_HOST = process.env.DIAL_API_HOST; export const DEFAULT_TEMPERATURE = parseFloat( process.env.NEXT_PUBLIC_DEFAULT_TEMPERATURE || '1', ); +export const MAX_ENTITY_LENGTH = 160; export const DEFAULT_CONVERSATION_NAME = 'New conversation'; export const DEFAULT_PROMPT_NAME = 'New prompt'; diff --git a/apps/chat/src/utils/app/common.ts b/apps/chat/src/utils/app/common.ts index ca8783c6a5..20b18a78a8 100644 --- a/apps/chat/src/utils/app/common.ts +++ b/apps/chat/src/utils/app/common.ts @@ -1,4 +1,4 @@ -import { getNextDefaultName } from '@/src/utils/app/folders'; +import { notAllowedSymbolsRegex } from '@/src/utils/app/file'; import { getFoldersFromPaths } from '@/src/utils/app/folders'; import { Conversation } from '@/src/types/chat'; @@ -8,6 +8,8 @@ import { FolderInterface, FolderType } from '@/src/types/folder'; import { Prompt } from '@/src/types/prompt'; import { PromptInfo } from '@/src/types/prompt'; +import { MAX_ENTITY_LENGTH } from '@/src/constants/default-settings'; + /** * Combine entities. If there are the same ids then will be used entity from entities1 i.e. first in array * @param entities1 @@ -26,28 +28,19 @@ export const combineEntities = ( ); }; -export const getSameLevelEntitiesWithUniqueNames = < - T extends Prompt | Conversation, +export const isEntityNameOnSameLevelUnique = < + T extends Conversation | Prompt | FolderInterface, >( + nameToBeUnique: string, + entity: T, entities: T[], -) => { - const folderGroups: Record> = {}; - - entities.forEach((entity) => { - const folderId = entity.folderId || ''; - - if (!folderGroups[folderId]) { - folderGroups[folderId] = {}; - } - if (!folderGroups[folderId][entity.name]) { - folderGroups[folderId][entity.name] = 1; - } else { - folderGroups[folderId][entity.name]++; - entity.name = getNextDefaultName(entity.name, entities); - } - }); - - return entities; +): boolean => { + const sameLevelEntities = entities.filter( + (e) => + entity.id !== e.id && + (e.folderId === entity.folderId || (!entity.folderId && !e.folderId)), + ); + return !sameLevelEntities.some((e) => nameToBeUnique === e.name); }; export const filterOnlyMyEntities = < @@ -97,3 +90,13 @@ export const updateEntitiesFoldersAndIds = ( return { updatedFolders, updatedOpenedFoldersIds }; }; + +export const prepareEntityName = (name: string) => { + const clearName = name.trim().replace(notAllowedSymbolsRegex, ''); + + if (clearName.length > MAX_ENTITY_LENGTH) { + return clearName.substring(0, MAX_ENTITY_LENGTH - 3) + '...'; + } + + return clearName; +}; diff --git a/apps/chat/src/utils/app/conversation.ts b/apps/chat/src/utils/app/conversation.ts index 8a60018599..129ad17426 100644 --- a/apps/chat/src/utils/app/conversation.ts +++ b/apps/chat/src/utils/app/conversation.ts @@ -1,3 +1,5 @@ +import { prepareEntityName } from '@/src/utils/app/common'; + import { Conversation, ConversationInfo, @@ -9,7 +11,7 @@ import { EntityType, PartialBy, UploadStatus } from '@/src/types/common'; import { OpenAIEntityAddon, OpenAIEntityModel } from '@/src/types/openai'; import { getConversationApiKey, parseConversationApiKey } from '../server/api'; -import { constructPath, notAllowedSymbolsRegex } from './file'; +import { constructPath } from './file'; import { compareEntitiesByName, splitPath } from './folders'; export const getAssitantModelId = ( @@ -79,24 +81,24 @@ export const getNewConversationName = ( message: Message, updatedMessages: Message[], ): string => { + const convName = prepareEntityName(conversation.name); + if ( conversation.replay.isReplay || updatedMessages.length !== 2 || conversation.isNameChanged ) { - return conversation.name; + return convName; } - const content = message.content - .replaceAll(notAllowedSymbolsRegex, ' ') - .trim(); + const content = prepareEntityName(message.content); if (content.length > 0) { - return content.length > 160 ? content.substring(0, 157) + '...' : content; + return content; } else if (message.custom_content?.attachments?.length) { const files = message.custom_content.attachments; return files[0].title; } - return conversation.name; + return convName; }; export const getGeneratedConversationId = (