diff --git a/.ort.yml b/.ort.yml index a3c52d39de..6576076e2a 100644 --- a/.ort.yml +++ b/.ort.yml @@ -11,6 +11,8 @@ license_choices: repository_license_choices: - given: 'GPL-2.0-only OR MIT' choice: 'MIT' + - given: 'GPL-3.0-or-later OR MIT' + choice: 'MIT' resolutions: rule_violations: - message: "No license information is available for dependency \ diff --git a/apps/chat-e2e/src/testData/conversationHistory/importConversation.ts b/apps/chat-e2e/src/testData/conversationHistory/importConversation.ts index 2f0ceae6b5..a650b3b056 100644 --- a/apps/chat-e2e/src/testData/conversationHistory/importConversation.ts +++ b/apps/chat-e2e/src/testData/conversationHistory/importConversation.ts @@ -1,5 +1,5 @@ import { Conversation } from '@/chat/types/chat'; -import { LatestExportFormat } from '@/chat/types/export'; +import { LatestExportFormat } from '@/chat/types/importExport'; import { FolderConversation } from '@/src/testData'; import { UploadDownloadData } from '@/src/ui/pages'; import { FileUtil } from '@/src/utils/fileUtil'; diff --git a/apps/chat-e2e/src/testData/expectedConstants.ts b/apps/chat-e2e/src/testData/expectedConstants.ts index 5fb1ad442f..4e46e4e7f3 100644 --- a/apps/chat-e2e/src/testData/expectedConstants.ts +++ b/apps/chat-e2e/src/testData/expectedConstants.ts @@ -66,6 +66,8 @@ export enum MenuOptions { replay = 'Replay', playback = 'Playback', export = 'Export', + withAttachments = 'With attachments', + withoutAttachments = 'Without attachments', moveTo = 'Move to', share = 'Share', publish = 'Publish', @@ -111,8 +113,8 @@ export const Import = { oldVersionAppFolderName: 'Version 1.x', oldVersionAppFolderChatName: '3-5 GPT math', v14AppBisonChatName: 'bison chat king', - v14AppImportedFilename: 'chatbot_ui_history_1-4_version.json', - v19AppImportedFilename: 'chatbot_ui_history_1-9_version.json', + v14AppImportedFilename: 'ai_dial_chat_history_1-4_version.json', + v19AppImportedFilename: 'ai_dial_chat_history_1-9_version.json', v14AppFolderPromptName: 'Version 1.4 A*B', oldVersionAppGpt35Message: '11 * 12 =', }; diff --git a/apps/chat-e2e/src/testData/import/chatbot_ui_history_1-4_version.json b/apps/chat-e2e/src/testData/import/ai_dial_chat_history_1-4_version.json similarity index 100% rename from apps/chat-e2e/src/testData/import/chatbot_ui_history_1-4_version.json rename to apps/chat-e2e/src/testData/import/ai_dial_chat_history_1-4_version.json diff --git a/apps/chat-e2e/src/testData/import/chatbot_ui_history_1-9_version.json b/apps/chat-e2e/src/testData/import/ai_dial_chat_history_1-9_version.json similarity index 100% rename from apps/chat-e2e/src/testData/import/chatbot_ui_history_1-9_version.json rename to apps/chat-e2e/src/testData/import/ai_dial_chat_history_1-9_version.json diff --git a/apps/chat-e2e/src/tests/chatExportImport.test.ts b/apps/chat-e2e/src/tests/chatExportImport.test.ts index ab8e3b1b69..0358eecbc9 100644 --- a/apps/chat-e2e/src/tests/chatExportImport.test.ts +++ b/apps/chat-e2e/src/tests/chatExportImport.test.ts @@ -80,8 +80,11 @@ test( conversationInFolder.folders.name, conversationInFolder.conversations[0].name, ); + await conversationDropdownMenu.selectMenuOption(MenuOptions.export); exportedData = await dialHomePage.downloadData(() => - conversationDropdownMenu.selectMenuOption(MenuOptions.export), + conversationDropdownMenu.selectMenuOption( + MenuOptions.withoutAttachments, + ), ); }); @@ -526,8 +529,11 @@ test( nestedFolders[levelsCount].name, nestedConversations[levelsCount].name, ); + await conversationDropdownMenu.selectMenuOption(MenuOptions.export); exportedData = await dialHomePage.downloadData(() => - conversationDropdownMenu.selectMenuOption(MenuOptions.export), + conversationDropdownMenu.selectMenuOption( + MenuOptions.withoutAttachments, + ), ); }); @@ -644,8 +650,12 @@ test('Import a chat in nested folder', async ({ nestedFolders[i].name, nestedConversations[i].name, ); + await conversationDropdownMenu.selectMenuOption(MenuOptions.export); const exportedData = await dialHomePage.downloadData( - () => conversationDropdownMenu.selectMenuOption(MenuOptions.export), + () => + conversationDropdownMenu.selectMenuOption( + MenuOptions.withoutAttachments, + ), `${i}.json`, ); exportedConversations.push(exportedData); @@ -750,8 +760,9 @@ test('Import a chat from nested folder which was moved to another place', async nestedFolders[levelsCount].name, thirdLevelFolderConversation.name, ); + await conversationDropdownMenu.selectMenuOption(MenuOptions.export); exportedData = await dialHomePage.downloadData(() => - conversationDropdownMenu.selectMenuOption(MenuOptions.export), + conversationDropdownMenu.selectMenuOption(MenuOptions.withoutAttachments), ); }); diff --git a/apps/chat/public/images/icons/loader.svg b/apps/chat/public/images/icons/loader.svg index 34eeb54b4b..30c9f21687 100644 --- a/apps/chat/public/images/icons/loader.svg +++ b/apps/chat/public/images/icons/loader.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/apps/chat/src/components/Chat/Addons.tsx b/apps/chat/src/components/Chat/Addons.tsx index b999f8637c..560916df71 100644 --- a/apps/chat/src/components/Chat/Addons.tsx +++ b/apps/chat/src/components/Chat/Addons.tsx @@ -11,7 +11,7 @@ import { Translation } from '@/src/types/translation'; import { AddonsSelectors } from '@/src/store/addons/addons.reducers'; import { useAppSelector } from '@/src/store/hooks'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; +import { ModelIcon } from '../Chatbar/ModelIcon'; import { EntityMarkdownDescription } from '../Common/MarkdownDescription'; import Tooltip from '../Common/Tooltip'; diff --git a/apps/chat/src/components/Chat/AddonsDialog.tsx b/apps/chat/src/components/Chat/AddonsDialog.tsx index 8c889ab415..6bfebbd74b 100644 --- a/apps/chat/src/components/Chat/AddonsDialog.tsx +++ b/apps/chat/src/components/Chat/AddonsDialog.tsx @@ -19,8 +19,7 @@ import { Translation } from '@/src/types/translation'; import { AddonsSelectors } from '@/src/store/addons/addons.reducers'; import { useAppSelector } from '@/src/store/hooks'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; - +import { ModelIcon } from '../Chatbar/ModelIcon'; import { EntityMarkdownDescription } from '../Common/MarkdownDescription'; import { NoResultsFound } from '../Common/NoResultsFound'; diff --git a/apps/chat/src/components/Chat/ChatCompareSelect.tsx b/apps/chat/src/components/Chat/ChatCompareSelect.tsx index 5e39282dae..3442760b1e 100644 --- a/apps/chat/src/components/Chat/ChatCompareSelect.tsx +++ b/apps/chat/src/components/Chat/ChatCompareSelect.tsx @@ -12,8 +12,7 @@ import { useAppSelector } from '@/src/store/hooks'; import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; - +import { ModelIcon } from '../Chatbar/ModelIcon'; import { Combobox } from '../Common/Combobox'; import ShareIcon from '../Common/ShareIcon'; diff --git a/apps/chat/src/components/Chat/ChatHeader.tsx b/apps/chat/src/components/Chat/ChatHeader.tsx index 3ce7e765a5..75998408ff 100644 --- a/apps/chat/src/components/Chat/ChatHeader.tsx +++ b/apps/chat/src/components/Chat/ChatHeader.tsx @@ -24,9 +24,9 @@ import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { UISelectors } from '@/src/store/ui/ui.reducers'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; import { ConfirmDialog } from '@/src/components/Common/ConfirmDialog'; +import { ModelIcon } from '../Chatbar/ModelIcon'; import Tooltip from '../Common/Tooltip'; import { ChatInfoTooltip } from './ChatInfoTooltip'; diff --git a/apps/chat/src/components/Chat/ChatInfoTooltip.tsx b/apps/chat/src/components/Chat/ChatInfoTooltip.tsx index 62a93c3695..c453d4936e 100644 --- a/apps/chat/src/components/Chat/ChatInfoTooltip.tsx +++ b/apps/chat/src/components/Chat/ChatInfoTooltip.tsx @@ -9,7 +9,7 @@ import { EntityType } from '@/src/types/common'; import { OpenAIEntityAddon, OpenAIEntityModel } from '@/src/types/openai'; import { Translation } from '@/src/types/translation'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; +import { ModelIcon } from '../Chatbar/ModelIcon'; interface Props { model: OpenAIEntityModel | ConversationEntityModel; diff --git a/apps/chat/src/components/Chat/ChatLoader.tsx b/apps/chat/src/components/Chat/ChatLoader.tsx new file mode 100644 index 0000000000..7533766560 --- /dev/null +++ b/apps/chat/src/components/Chat/ChatLoader.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; + +import { Spinner } from '../Common/Spinner'; + +interface Props { + size?: number; + slow?: boolean; + containerClassName?: string; + loaderClassName?: string; + dataQa?: string; +} + +export default function ChatLoader({ + size = 45, + containerClassName, + loaderClassName, + dataQa = 'chat-loader', +}: Props) { + return ( +
+ +
+ ); +} diff --git a/apps/chat/src/components/Chat/ChatMessage.tsx b/apps/chat/src/components/Chat/ChatMessage.tsx index dc35ef03ec..f6651682d4 100644 --- a/apps/chat/src/components/Chat/ChatMessage.tsx +++ b/apps/chat/src/components/Chat/ChatMessage.tsx @@ -39,9 +39,9 @@ import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; import { UISelectors } from '@/src/store/ui/ui.reducers'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; import Tooltip from '@/src/components/Common/Tooltip'; +import { ModelIcon } from '../Chatbar/ModelIcon'; import { ConfirmDialog } from '../Common/ConfirmDialog'; import { ErrorMessage } from '../Common/ErrorMessage'; import { AttachButton } from '../Files/AttachButton'; diff --git a/apps/chat/src/components/Chat/ConversationSettings.tsx b/apps/chat/src/components/Chat/ConversationSettings.tsx index 6149555a71..1756ae659f 100644 --- a/apps/chat/src/components/Chat/ConversationSettings.tsx +++ b/apps/chat/src/components/Chat/ConversationSettings.tsx @@ -14,8 +14,7 @@ import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { DEFAULT_ASSISTANT_SUBMODEL } from '@/src/constants/default-settings'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; - +import { ModelIcon } from '../Chatbar/ModelIcon'; import { Addons } from './Addons'; import { AssistantSubModelSelector } from './AssistantSubModelSelector'; import { ConversationSettingsModel } from './ConversationSettingsModels'; diff --git a/apps/chat/src/components/Chat/ConversationSettingsModels.tsx b/apps/chat/src/components/Chat/ConversationSettingsModels.tsx index 50b40e2f40..3f4b0a113f 100644 --- a/apps/chat/src/components/Chat/ConversationSettingsModels.tsx +++ b/apps/chat/src/components/Chat/ConversationSettingsModels.tsx @@ -14,8 +14,7 @@ import { Translation } from '@/src/types/translation'; import { useAppSelector } from '@/src/store/hooks'; import { ModelsSelectors } from '@/src/store/models/models.reducers'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; - +import { ModelIcon } from '../Chatbar/ModelIcon'; import { EntityMarkdownDescription } from '../Common/MarkdownDescription'; import { ModelsDialog } from './ModelsDialog'; import { ReplayAsIsButton } from './ReplayAsIsButton'; diff --git a/apps/chat/src/components/Chat/MessageStage.tsx b/apps/chat/src/components/Chat/MessageStage.tsx index 5a867f8048..3b7e337c8f 100644 --- a/apps/chat/src/components/Chat/MessageStage.tsx +++ b/apps/chat/src/components/Chat/MessageStage.tsx @@ -9,7 +9,7 @@ import { OpenAIEntityAddon } from '@/src/types/openai'; import { AddonsSelectors } from '@/src/store/addons/addons.reducers'; import { useAppSelector } from '@/src/store/hooks'; -import { ModelIcon } from '@/src/components/Chatbar/components/ModelIcon'; +import { ModelIcon } from '@/src/components/Chatbar/ModelIcon'; import ChevronDown from '../../../public/images/icons/chevron-down.svg'; import CircleCheck from '../../../public/images/icons/circle-check.svg'; diff --git a/apps/chat/src/components/Chat/ModelDescription.tsx b/apps/chat/src/components/Chat/ModelDescription.tsx index a7e80234ab..274a9453ea 100644 --- a/apps/chat/src/components/Chat/ModelDescription.tsx +++ b/apps/chat/src/components/Chat/ModelDescription.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'next-i18next'; import { OpenAIEntityModel } from '@/src/types/openai'; import { Translation } from '@/src/types/translation'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; - +import { ModelIcon } from '../Chatbar/ModelIcon'; import { EntityMarkdownDescription } from '../Common/MarkdownDescription'; interface Props { diff --git a/apps/chat/src/components/Chat/ModelsDialog.tsx b/apps/chat/src/components/Chat/ModelsDialog.tsx index 63a7fc6580..22177c408d 100644 --- a/apps/chat/src/components/Chat/ModelsDialog.tsx +++ b/apps/chat/src/components/Chat/ModelsDialog.tsx @@ -23,8 +23,7 @@ import { ModelsSelectors, } from '@/src/store/models/models.reducers'; -import { ModelIcon } from '../Chatbar/components/ModelIcon'; - +import { ModelIcon } from '../Chatbar/ModelIcon'; import { EntityMarkdownDescription } from '../Common/MarkdownDescription'; import { NoResultsFound } from '../Common/NoResultsFound'; diff --git a/apps/chat/src/components/Chatbar/components/ChatFolders.tsx b/apps/chat/src/components/Chatbar/ChatFolders.tsx similarity index 98% rename from apps/chat/src/components/Chatbar/components/ChatFolders.tsx rename to apps/chat/src/components/Chatbar/ChatFolders.tsx index 7b416bf3b3..d7217a013d 100644 --- a/apps/chat/src/components/Chatbar/components/ChatFolders.tsx +++ b/apps/chat/src/components/Chatbar/ChatFolders.tsx @@ -30,8 +30,8 @@ import { import Folder from '@/src/components/Folder/Folder'; -import CollapsableSection from '../../Common/CollapsableSection'; -import { BetweenFoldersLine } from '../../Sidebar/BetweenFoldersLine'; +import CollapsableSection from '../Common/CollapsableSection'; +import { BetweenFoldersLine } from '../Sidebar/BetweenFoldersLine'; import { ConversationComponent } from './Conversation'; interface ChatFolderProps { diff --git a/apps/chat/src/components/Chatbar/Chatbar.tsx b/apps/chat/src/components/Chatbar/Chatbar.tsx index 93205a4bc1..d35b2b3a79 100644 --- a/apps/chat/src/components/Chatbar/Chatbar.tsx +++ b/apps/chat/src/components/Chatbar/Chatbar.tsx @@ -18,12 +18,11 @@ import { UISelectors } from '@/src/store/ui/ui.reducers'; import { DEFAULT_CONVERSATION_NAME } from '@/src/constants/default-settings'; -import { ChatFolders } from './components/ChatFolders'; -import { ChatbarSettings } from './components/ChatbarSettings'; -import { Conversations } from './components/Conversations'; - import PlusIcon from '../../../public/images/icons/plus-large.svg'; import Sidebar from '../Sidebar'; +import { ChatFolders } from './ChatFolders'; +import { ChatbarSettings } from './ChatbarSettings'; +import { Conversations } from './Conversations'; const ChatActionsBlock = () => { const { t } = useTranslation(Translation.SideBar); diff --git a/apps/chat/src/components/Chatbar/components/ChatbarSettings.tsx b/apps/chat/src/components/Chatbar/ChatbarSettings.tsx similarity index 76% rename from apps/chat/src/components/Chatbar/components/ChatbarSettings.tsx rename to apps/chat/src/components/Chatbar/ChatbarSettings.tsx index 597be10eae..42a876f842 100644 --- a/apps/chat/src/components/Chatbar/components/ChatbarSettings.tsx +++ b/apps/chat/src/components/Chatbar/ChatbarSettings.tsx @@ -10,7 +10,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'next-i18next'; import { FeatureType } from '@/src/types/common'; -import { SupportedExportFormats } from '@/src/types/export'; +import { SupportedExportFormats } from '@/src/types/importExport'; import { DisplayMenuItemProps } from '@/src/types/menu'; import { Translation } from '@/src/types/translation'; @@ -19,6 +19,7 @@ import { ConversationsSelectors, } from '@/src/store/conversations/conversations.reducers'; import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; +import { ImportExportActions } from '@/src/store/import-export/importExport.reducers'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; import { DEFAULT_CONVERSATION_NAME } from '@/src/constants/default-settings'; @@ -57,6 +58,24 @@ export const ChatbarSettings = () => { ); }, [dispatch]); + const jsonImportHandler = useCallback( + (jsonContent: SupportedExportFormats) => { + dispatch( + ImportExportActions.importConversations({ + data: jsonContent as SupportedExportFormats, + }), + ); + }, + [dispatch], + ); + + const zipImportHandler = useCallback( + (zipFile: File) => { + dispatch(ImportExportActions.importZipConversations({ zipFile })); + }, + [dispatch], + ); + const menuItems: DisplayMenuItemProps[] = useMemo( () => [ { @@ -69,23 +88,27 @@ export const ChatbarSettings = () => { }, { name: t('Import conversations'), - onClick: (importJSON: unknown) => { - dispatch( - ConversationsActions.importConversations({ - data: importJSON as SupportedExportFormats, - }), - ); + onClick: (importArgs: unknown) => { + const typedArgs = importArgs as { content: unknown; zip?: boolean }; + + if (!typedArgs.zip) { + jsonImportHandler(typedArgs.content as SupportedExportFormats); + } + if (typedArgs.zip) { + zipImportHandler(typedArgs.content as File); + } }, Icon: IconFileArrowLeft, dataQa: 'import', CustomTriggerRenderer: Import, }, { - name: t('Export conversations'), + name: t('Export conversations without attachments'), dataQa: 'export', + className: 'max-w-[158px]', Icon: IconFileArrowRight, onClick: () => { - dispatch(ConversationsActions.exportConversations()); + dispatch(ImportExportActions.exportConversations()); }, }, { @@ -116,7 +139,15 @@ export const ChatbarSettings = () => { }, }, ], - [dispatch, enabledFeatures, handleToggleCompare, isStreaming, t], + [ + dispatch, + enabledFeatures, + handleToggleCompare, + isStreaming, + t, + jsonImportHandler, + zipImportHandler, + ], ); return ( diff --git a/apps/chat/src/components/Chatbar/components/Conversation.tsx b/apps/chat/src/components/Chatbar/Conversation.tsx similarity index 91% rename from apps/chat/src/components/Chatbar/components/Conversation.tsx rename to apps/chat/src/components/Chatbar/Conversation.tsx index ea58f176d1..e5b6bae8f8 100644 --- a/apps/chat/src/components/Chatbar/components/Conversation.tsx +++ b/apps/chat/src/components/Chatbar/Conversation.tsx @@ -27,6 +27,7 @@ import { ConversationsSelectors, } from '@/src/store/conversations/conversations.reducers'; import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; +import { ImportExportActions } from '@/src/store/import-export/importExport.reducers'; import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { UIActions } from '@/src/store/ui/ui.reducers'; @@ -38,8 +39,9 @@ import ItemContextMenu from '@/src/components/Common/ItemContextMenu'; import { MoveToFolderMobileModal } from '@/src/components/Common/MoveToFolderMobileModal'; import ShareIcon from '@/src/components/Common/ShareIcon'; -import PublishModal from '../../Chat/Publish/PublishWizard'; -import UnpublishModal from '../../Chat/UnpublishModal'; +import PublishModal from '../Chat/Publish/PublishWizard'; +import UnpublishModal from '../Chat/UnpublishModal'; +import { ExportModal } from './ExportModal'; import { ModelIcon } from './ModelIcon'; import { v4 as uuidv4 } from 'uuid'; @@ -126,6 +128,7 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(''); const [isShowMoveToModal, setIsShowMoveToModal] = useState(false); + const [isShowExportModal, setIsShowExportModal] = useState(false); const buttonRef = useRef(null); const inputRef = useRef(null); const [isSharing, setIsSharing] = useState(false); @@ -344,6 +347,34 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { }, [conversation.id, dispatch], ); + const handleOpenExportModal = useCallback(() => { + setIsShowExportModal(true); + }, []); + const handleCloseExportModal = useCallback(() => { + setIsShowExportModal(false); + }, []); + + const handleExport = useCallback( + (args?: unknown) => { + const typedArgs = args as { withAttachments?: boolean }; + if (typedArgs?.withAttachments) { + dispatch( + ImportExportActions.exportConversation({ + conversationId: conversation.id, + withAttachments: true, + }), + ); + } else { + dispatch( + ImportExportActions.exportConversation({ + conversationId: conversation.id, + }), + ); + } + handleCloseExportModal(); + }, + [conversation.id, dispatch, handleCloseExportModal], + ); const handleContextMenuOpen = (e: MouseEvent) => { if (hasParentWithFloatingOverlay(e.target as Element)) { @@ -459,13 +490,8 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { onMoveToFolder={handleMoveToFolder} onDelete={handleOpenDeleteModal} onRename={handleOpenRenameModal} - onExport={() => { - dispatch( - ConversationsActions.exportConversation({ - conversationId: conversation.id, - }), - ); - }} + onExport={handleExport} + onOpenExportModal={handleOpenExportModal} onCompare={!isReplay && !isPlayback ? handleCompare : undefined} onDuplicate={handleDuplicate} onReplay={!isPlayback ? handleStartReplay : undefined} @@ -490,6 +516,15 @@ export const ConversationComponent = ({ item: conversation, level }: Props) => { /> )} +
+ {isShowExportModal && ( + + )} +
{(isDeleting || isRenaming) && (
diff --git a/apps/chat/src/components/Chatbar/components/Conversations.tsx b/apps/chat/src/components/Chatbar/Conversations.tsx similarity index 100% rename from apps/chat/src/components/Chatbar/components/Conversations.tsx rename to apps/chat/src/components/Chatbar/Conversations.tsx diff --git a/apps/chat/src/components/Chatbar/components/ConversationsRenderer.tsx b/apps/chat/src/components/Chatbar/ConversationsRenderer.tsx similarity index 95% rename from apps/chat/src/components/Chatbar/components/ConversationsRenderer.tsx rename to apps/chat/src/components/Chatbar/ConversationsRenderer.tsx index c394bd53ca..41b63eedfb 100644 --- a/apps/chat/src/components/Chatbar/components/ConversationsRenderer.tsx +++ b/apps/chat/src/components/Chatbar/ConversationsRenderer.tsx @@ -5,7 +5,7 @@ import { Conversation } from '@/src/types/chat'; import { ConversationsSelectors } from '@/src/store/conversations/conversations.reducers'; import { useAppSelector } from '@/src/store/hooks'; -import CollapsableSection from '../../Common/CollapsableSection'; +import CollapsableSection from '../Common/CollapsableSection'; import { ConversationComponent } from './Conversation'; interface ConversationsRendererProps { diff --git a/apps/chat/src/components/Chatbar/ExportModal.tsx b/apps/chat/src/components/Chatbar/ExportModal.tsx new file mode 100644 index 0000000000..794f253fc2 --- /dev/null +++ b/apps/chat/src/components/Chatbar/ExportModal.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'next-i18next'; + +import { Translation } from '@/src/types/translation'; + +import Modal from '../Common/Modal'; + +interface Props { + onExport: (args?: { withAttachments?: boolean }) => void; + onClose: () => void; + isOpen: boolean; +} +export const ExportModal = ({ onExport, onClose, isOpen }: Props) => { + const { t } = useTranslation(Translation.SideBar); + return ( + +

{t('Export')}

+
+ + +
+
+ ); +}; diff --git a/apps/chat/src/components/Chatbar/ImportExportLoader.tsx b/apps/chat/src/components/Chatbar/ImportExportLoader.tsx new file mode 100644 index 0000000000..ed732d8d03 --- /dev/null +++ b/apps/chat/src/components/Chatbar/ImportExportLoader.tsx @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; + +import { useTranslation } from 'next-i18next'; + +import { Operation } from '@/src/types/importExport'; +import { Translation } from '@/src/types/translation'; + +import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; +import { + ImportExportActions, + ImportExportSelectors, +} from '@/src/store/import-export/importExport.reducers'; + +import { FullPageLoader } from '../Common/FullPageLoader'; + +interface Props { + isOpen: boolean; +} +export const ImportExportLoader = ({ isOpen }: Props) => { + const { t } = useTranslation(Translation.Chat); + const dispatch = useAppDispatch(); + const operationName = + useAppSelector(ImportExportSelectors.selectOperationName) ?? ''; + const stopLabel = operationName === Operation.Importing ? 'Stop' : 'Cancel'; + + const handleCancelExport = useCallback(() => { + dispatch(ImportExportActions.exportCancel()); + }, [dispatch]); + + const handleStopImport = useCallback(() => { + dispatch(ImportExportActions.importStop()); + }, [dispatch]); + + const onStop = + operationName === Operation.Importing + ? handleStopImport + : handleCancelExport; + return ( + { + return; + }} + onStop={onStop} + stopLabel={t(stopLabel)} + /> + ); +}; diff --git a/apps/chat/src/components/Chatbar/components/ModelIcon.tsx b/apps/chat/src/components/Chatbar/ModelIcon.tsx similarity index 100% rename from apps/chat/src/components/Chatbar/components/ModelIcon.tsx rename to apps/chat/src/components/Chatbar/ModelIcon.tsx diff --git a/apps/chat/src/components/Common/FullPageLoader.tsx b/apps/chat/src/components/Common/FullPageLoader.tsx new file mode 100644 index 0000000000..8b611dc605 --- /dev/null +++ b/apps/chat/src/components/Common/FullPageLoader.tsx @@ -0,0 +1,46 @@ +import Modal, { Props as ModalProps } from './Modal'; +import { Spinner } from './Spinner'; + +interface Props { + onClose: ModalProps['onClose']; + isOpen: ModalProps['isOpen']; + hideClose?: ModalProps['hideClose']; + dataQa?: ModalProps['dataQa']; + portalId?: ModalProps['portalId']; + onStop: () => void; + loaderLabel: string; + stopLabel: string; + spinnerSize?: number; +} +export const FullPageLoader = ({ + onClose, + isOpen, + hideClose = true, + dataQa = 'import-export-loader', + portalId = 'theme-main', + onStop, + loaderLabel, + stopLabel, + spinnerSize = 50, +}: Props) => { + return ( + + + +

{loaderLabel}

+ +
+ ); +}; diff --git a/apps/chat/src/components/Common/ItemContextMenu.tsx b/apps/chat/src/components/Common/ItemContextMenu.tsx index 9bc7bcf102..aa264ed244 100644 --- a/apps/chat/src/components/Common/ItemContextMenu.tsx +++ b/apps/chat/src/components/Common/ItemContextMenu.tsx @@ -41,10 +41,11 @@ interface ItemContextMenuProps { className?: string; isOpen?: boolean; onOpenMoveToModal: () => void; + onOpenExportModal?: () => void; onMoveToFolder: (args: { folderId?: string; isNewFolder?: boolean }) => void; onDelete: MouseEventHandler; onRename: MouseEventHandler; - onExport: MouseEventHandler; + onExport: (args?: unknown) => void; onReplay?: MouseEventHandler; onCompare?: MouseEventHandler; onPlayback?: MouseEventHandler; @@ -66,6 +67,7 @@ export default function ItemContextMenu({ onDelete, onRename, onExport, + onOpenExportModal, onReplay, onCompare, onPlayback, @@ -127,10 +129,44 @@ export default function ItemContextMenu({ }, { name: t('Export'), - dataQa: 'export', + dataQa: 'export-prompt', + display: featureType === FeatureType.Prompt, Icon: IconFileArrowRight, onClick: onExport, }, + { + name: t('Export'), + dataQa: 'export-chat-mobile', + display: featureType === FeatureType.Chat, + Icon: IconFileArrowRight, + onClick: onOpenExportModal, + className: 'md:hidden', + }, + { + name: t('Export'), + display: featureType === FeatureType.Chat, + dataQa: 'export-chat', + Icon: IconFileArrowRight, + className: 'max-md:hidden', + childMenuItems: [ + { + name: t('With attachments'), + dataQa: 'with-attachments', + onClick: () => { + onExport({ withAttachments: true }); + }, + className: 'invisible md:visible', + }, + { + name: t('Without attachments'), + dataQa: 'without-attachments', + onClick: () => { + onExport(); + }, + className: 'invisible md:visible', + }, + ], + }, { name: t('Move to'), display: !isExternal, @@ -217,6 +253,7 @@ export default function ItemContextMenu({ onReplay, onPlayback, onExport, + onOpenExportModal, onOpenMoveToModal, folders, isSharingEnabled, diff --git a/apps/chat/src/components/Common/Modal.tsx b/apps/chat/src/components/Common/Modal.tsx index 5eb7cf9334..8cc181bcaf 100644 --- a/apps/chat/src/components/Common/Modal.tsx +++ b/apps/chat/src/components/Common/Modal.tsx @@ -21,7 +21,7 @@ import { import classNames from 'classnames'; -interface Props extends FormHTMLAttributes { +export interface Props extends FormHTMLAttributes { portalId: string; isOpen: boolean; onClose: () => void; diff --git a/apps/chat/src/components/Common/SidebarMenu.tsx b/apps/chat/src/components/Common/SidebarMenu.tsx index f4adc26ba1..d5589470f2 100644 --- a/apps/chat/src/components/Common/SidebarMenu.tsx +++ b/apps/chat/src/components/Common/SidebarMenu.tsx @@ -110,7 +110,12 @@ export default function SidebarMenu({ ); return ( - + {Trigger} ); diff --git a/apps/chat/src/components/Common/Spinner.tsx b/apps/chat/src/components/Common/Spinner.tsx index 1797b90306..10ca4c64e0 100644 --- a/apps/chat/src/components/Common/Spinner.tsx +++ b/apps/chat/src/components/Common/Spinner.tsx @@ -1,19 +1,28 @@ import classNames from 'classnames'; +import LoaderIcon from '@/public/images/icons/loader.svg'; + interface Props { size?: number; className?: string; + dataQa?: string; } -export const Spinner = ({ size = 16, className = '' }: Props) => { +export const Spinner = ({ + size = 16, + className = '', + dataQa = 'message-input-spinner', +}: Props) => { return ( -
+ data-qa={dataQa} + /> ); }; diff --git a/apps/chat/src/components/Promptbar/components/Prompt.tsx b/apps/chat/src/components/Promptbar/components/Prompt.tsx index 20084c01c0..a8b9106772 100644 --- a/apps/chat/src/components/Promptbar/components/Prompt.tsx +++ b/apps/chat/src/components/Promptbar/components/Prompt.tsx @@ -177,10 +177,11 @@ export const PromptComponent = ({ item: prompt, level }: Props) => { [dispatch, prompt.id], ); - const handleExportPrompt: MouseEventHandler = useCallback( - (e) => { - e.preventDefault(); - e.stopPropagation(); + const handleExportPrompt = useCallback( + (e?: unknown) => { + const typedEvent = e as MouseEvent; + typedEvent.preventDefault(); + typedEvent.stopPropagation(); dispatch( PromptsActions.exportPrompt({ diff --git a/apps/chat/src/components/Promptbar/components/PromptbarSettings.tsx b/apps/chat/src/components/Promptbar/components/PromptbarSettings.tsx index fd9c93367a..e764d34b64 100644 --- a/apps/chat/src/components/Promptbar/components/PromptbarSettings.tsx +++ b/apps/chat/src/components/Promptbar/components/PromptbarSettings.tsx @@ -8,7 +8,7 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'next-i18next'; import { FeatureType } from '@/src/types/common'; -import { PromptsHistory } from '@/src/types/export'; +import { PromptsHistory } from '@/src/types/importExport'; import { DisplayMenuItemProps } from '@/src/types/menu'; import { Translation } from '@/src/types/translation'; @@ -46,9 +46,10 @@ export function PromptbarSettings() { { name: t('Import prompts'), onClick: (promptsJSON: unknown) => { + const typedJson = promptsJSON as { content: unknown }; dispatch( PromptsActions.importPrompts({ - promptsHistory: promptsJSON as PromptsHistory, + promptsHistory: typedJson.content as PromptsHistory, }), ); }, diff --git a/apps/chat/src/components/Settings/Import.tsx b/apps/chat/src/components/Settings/Import.tsx index f14e02ac39..f74e4d5b1f 100644 --- a/apps/chat/src/components/Settings/Import.tsx +++ b/apps/chat/src/components/Settings/Import.tsx @@ -1,13 +1,28 @@ -import { FC, useRef } from 'react'; +import { FC, MouseEvent, useRef } from 'react'; +import toast from 'react-hot-toast'; import { CustomTriggerMenuRendererProps } from '@/src/types/menu'; +import { errorsMessages } from '@/src/constants/errors'; + export const Import: FC = ({ Renderer, onClick: onImport, ...rendererProps }) => { const ref = useRef(null); + + const typedImportHandler = onImport as ({ + content, + zip, + }: { + content: File; + zip?: boolean; + }) => void | undefined; + + const onClickHandler = (e: MouseEvent) => { + e.currentTarget.value = ''; + }; return ( <> = ({ className="sr-only" tabIndex={-1} type="file" - accept=".json" + accept="application/json, application/x-zip-compressed, application/zip" + onClick={onClickHandler} onChange={(e) => { if (!e.target.files?.length) return; - const file = e.target.files[0]; - const reader = new FileReader(); - reader.onload = (readerEvent) => { - const json = JSON.parse(readerEvent.target?.result as string); - onImport?.(json); - (ref.current as unknown as HTMLInputElement).value = ''; - }; - reader.readAsText(file); + + if ( + file.type === 'application/zip' || + file.type === 'application/x-zip-compressed' + ) { + typedImportHandler?.({ content: file, zip: true }); + return; + } + + if (file.type === 'application/json') { + const reader = new FileReader(); + reader.onload = (readerEvent) => { + const json = JSON.parse(readerEvent.target?.result as string); + typedImportHandler?.({ content: json }); + }; + reader.readAsText(file); + return; + } + + toast.error(errorsMessages.unsupportedDataFormat); }} /> + + {isImportingExporting && ( + + )}
{enabledFeatures.has(Feature.PromptsSection) && } diff --git a/apps/chat/src/store/conversations/conversations.epics.ts b/apps/chat/src/store/conversations/conversations.epics.ts index 451ead2a3b..de4085be91 100644 --- a/apps/chat/src/store/conversations/conversations.epics.ts +++ b/apps/chat/src/store/conversations/conversations.epics.ts @@ -1,5 +1,3 @@ -import toast from 'react-hot-toast'; - import { EMPTY, Observable, @@ -43,12 +41,6 @@ import { getFolderIdByPath, getTemporaryFoldersToPublish, } from '@/src/utils/app/folders'; -import { - ImportConversationsResponse, - exportConversation, - exportConversations, - importConversations, -} from '@/src/utils/app/import-export'; import { mergeMessages, parseStreamMessages, @@ -161,68 +153,6 @@ const deleteFolderEpic: AppEpic = (action$, state$) => }), ); -const exportConversationEpic: AppEpic = (action$, state$) => - action$.pipe( - filter(ConversationsActions.exportConversation.match), - map(({ payload }) => - ConversationsSelectors.selectConversation( - state$.value, - payload.conversationId, - ), - ), - filter(Boolean), - tap((conversation) => { - const parentFolders = ConversationsSelectors.selectParentFolders( - state$.value, - conversation.folderId, - ); - - exportConversation(conversation, parentFolders); - }), - ignoreElements(), - ); - -const exportConversationsEpic: AppEpic = (action$, state$) => - action$.pipe( - filter(ConversationsActions.exportConversations.match), - map(() => ({ - conversations: ConversationsSelectors.selectConversations(state$.value), - folders: ConversationsSelectors.selectFolders(state$.value), - })), - tap(({ conversations, folders }) => { - exportConversations(conversations, folders); - }), - ignoreElements(), - ); - -const importConversationsEpic: AppEpic = (action$, state$) => - action$.pipe( - filter(ConversationsActions.importConversations.match), - switchMap(({ payload }) => { - const currentConversations = ConversationsSelectors.selectConversations( - state$.value, - ); - const currentFolders = ConversationsSelectors.selectFolders(state$.value); - const { history, folders, isError }: ImportConversationsResponse = - importConversations(payload.data, { - currentConversations, - currentFolders, - }); - - if (isError) { - toast.error(errorsMessages.unsupportedDataFormat); - return EMPTY; - } - - return of( - ConversationsActions.importConversationsSuccess({ - conversations: history, - folders, - }), - ); - }), - ); - const clearConversationsEpic: AppEpic = (action$) => action$.pipe( filter(ConversationsActions.clearConversations.match), @@ -1662,9 +1592,6 @@ export const ConversationsEpics = combineEpics( saveConversationsEpic, saveFoldersEpic, deleteFolderEpic, - exportConversationEpic, - exportConversationsEpic, - importConversationsEpic, clearConversationsEpic, deleteConversationsEpic, updateMessageEpic, diff --git a/apps/chat/src/store/conversations/conversations.reducers.ts b/apps/chat/src/store/conversations/conversations.reducers.ts index 2af61bd7ec..887f05a72b 100644 --- a/apps/chat/src/store/conversations/conversations.reducers.ts +++ b/apps/chat/src/store/conversations/conversations.reducers.ts @@ -11,7 +11,6 @@ import { Role, } from '@/src/types/chat'; import { FeatureType } from '@/src/types/common'; -import { SupportedExportFormats } from '@/src/types/export'; import { FolderInterface, FolderType } from '@/src/types/folder'; import { SearchFilters } from '@/src/types/search'; import { PublishRequest } from '@/src/types/share'; @@ -216,10 +215,7 @@ export const conversationsSlice = createSlice({ return folder; }); }, - exportConversation: ( - state, - _action: PayloadAction<{ conversationId: string }>, - ) => state, + deleteConversations: ( state, { payload }: PayloadAction<{ conversationIds: string[] }>, @@ -362,11 +358,6 @@ export const conversationsSlice = createSlice({ state.conversations = state.conversations.concat(newConversations); state.selectedConversationsIds = newSelectedIds; }, - exportConversations: (state) => state, - importConversations: ( - state, - _action: PayloadAction<{ data: SupportedExportFormats }>, - ) => state, importConversationsSuccess: ( state, { diff --git a/apps/chat/src/store/conversations/conversations.selectors.ts b/apps/chat/src/store/conversations/conversations.selectors.ts index b52f1e9d89..0df3f3bcdc 100644 --- a/apps/chat/src/store/conversations/conversations.selectors.ts +++ b/apps/chat/src/store/conversations/conversations.selectors.ts @@ -460,7 +460,7 @@ export const selectNewAddedFolderId = createSelector( }, ); -const getUniqueAttachments = (attachments: DialFile[]): DialFile[] => { +export const getUniqueAttachments = (attachments: DialFile[]): DialFile[] => { const map = new Map(); attachments.forEach((file) => map.set(constructPath(file.relativePath, file.name), file), diff --git a/apps/chat/src/store/files/files.reducers.ts b/apps/chat/src/store/files/files.reducers.ts index b51c5f2c13..4c9c9955e7 100644 --- a/apps/chat/src/store/files/files.reducers.ts +++ b/apps/chat/src/store/files/files.reducers.ts @@ -6,13 +6,11 @@ import { getParentAndChildFolders, } from '@/src/utils/app/folders'; -import { DialFile, FileFolderInterface } from '@/src/types/files'; +import { DialFile, FileFolderInterface, Status } from '@/src/types/files'; import { FolderType } from '@/src/types/folder'; import { RootState } from '../index'; -type Status = undefined | 'LOADING' | 'LOADED' | 'FAILED'; - export interface FilesState { files: DialFile[]; bucket: string; diff --git a/apps/chat/src/store/import-export/importExport.epics.ts b/apps/chat/src/store/import-export/importExport.epics.ts new file mode 100644 index 0000000000..f0e7f93fbb --- /dev/null +++ b/apps/chat/src/store/import-export/importExport.epics.ts @@ -0,0 +1,508 @@ +import toast from 'react-hot-toast'; + +import { + EMPTY, + catchError, + concat, + filter, + from, + ignoreElements, + map, + mergeAll, + of, + switchMap, + takeUntil, + tap, +} from 'rxjs'; + +import { combineEpics } from 'redux-observable'; + +import { DataService } from '@/src/utils/app/data/data-service'; +import { getConversationAttachmentWithPath } from '@/src/utils/app/folders'; +import { + ImportConversationsResponse, + cleanData, + exportConversation, + exportConversations, + importConversations, +} from '@/src/utils/app/import-export'; +import { + compressConversationInZip, + downloadExportZip, + getUnZipAttachments, + importZippedHistory, +} from '@/src/utils/app/zip-import-export'; + +import { Conversation, Message } from '@/src/types/chat'; +import { LatestExportFormat } from '@/src/types/importExport'; +import { AppEpic } from '@/src/types/store'; + +import { errorsMessages } from '@/src/constants/errors'; + +import { + ConversationsActions, + ConversationsSelectors, +} from '../conversations/conversations.reducers'; +import { getUniqueAttachments } from '../conversations/conversations.selectors'; +import { FilesActions, FilesSelectors } from '../files/files.reducers'; +import { selectFolders } from '../prompts/prompts.selectors'; +import { + ImportExportActions, + ImportExportSelectors, +} from './importExport.reducers'; + +const firstConversationIndex = 0; + +const exportConversationEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.exportConversation.match), + map(({ payload }) => ({ + conversation: ConversationsSelectors.selectConversation( + state$.value, + payload.conversationId, + ), + withAttachments: payload.withAttachments, + bucket: FilesSelectors.selectBucket(state$.value), + })), + switchMap(({ conversation, withAttachments, bucket }) => { + if (!conversation) { + return of(ImportExportActions.exportFail()); + } + const parentFolders = ConversationsSelectors.selectParentFolders( + state$.value, + conversation.folderId, + ); + if (!withAttachments) { + exportConversation(conversation, parentFolders); + return of(ImportExportActions.exportConversationSuccess()); + } + + if (!bucket.length) { + return of(ImportExportActions.exportFail()); + } + const attachments = ConversationsSelectors.getAttachments( + state$.value, + conversation.id, + ); + + return from( + compressConversationInZip({ + attachments, + conversation, + parentFolders, + }), + ).pipe( + switchMap((content) => { + if (!content) { + return of(ImportExportActions.exportFail()); + } + downloadExportZip(content); + return of(ImportExportActions.exportConversationSuccess()); + }), + takeUntil(action$.pipe(filter(ImportExportActions.exportCancel.match))), + ); + }), + ); + +const exportConversationsEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.exportConversations.match), + map(() => ({ + conversations: ConversationsSelectors.selectConversations(state$.value), + folders: ConversationsSelectors.selectFolders(state$.value), + })), + tap(({ conversations, folders }) => { + exportConversations(conversations, folders); + }), + ignoreElements(), + ); + +const importConversationsEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.importConversations.match), + switchMap(({ payload }) => { + const currentConversations = ConversationsSelectors.selectConversations( + state$.value, + ); + const currentFolders = ConversationsSelectors.selectFolders(state$.value); + const { history, folders, isError }: ImportConversationsResponse = + importConversations(payload.data, { + currentConversations, + currentFolders, + }); + + if (isError) { + toast.error(errorsMessages.unsupportedDataFormat); + return of(ImportExportActions.resetState()); + } + + return concat( + of(ImportExportActions.importConversationsSuccess()), + of( + ConversationsActions.importConversationsSuccess({ + conversations: history, + folders, + }), + ), + ); + }), + ); + +const importZipEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.importZipConversations.match), + switchMap(({ payload }) => { + return from(importZippedHistory(payload.zipFile)).pipe( + switchMap((preUnzipedHistory) => { + const { zip } = preUnzipedHistory; + if (!preUnzipedHistory.history || !preUnzipedHistory.history.name) { + toast.error(errorsMessages.unsupportedDataFormat); + return of(ImportExportActions.importFail()); + } + const file = zip.file(preUnzipedHistory.history.name); + + if (!file) { + toast.error(errorsMessages.unsupportedDataFormat); + return of(ImportExportActions.importFail()); + } + + return from(file.async('string')).pipe( + switchMap((completeHistoryJson) => { + const completeHistoryParsed = JSON.parse(completeHistoryJson); + if (!completeHistoryParsed) { + toast.error(errorsMessages.unsupportedDataFormat); + return of(ImportExportActions.importFail()); + } + + const { + history: cleanConversations, + folders: cleanFolders, + prompts, + version, + isError, + } = cleanData(completeHistoryParsed); + + const cleanHistory: LatestExportFormat = { + version, + history: cleanConversations, + folders: cleanFolders, + prompts, + }; + + if (isError) { + toast.error(errorsMessages.unsupportedDataFormat); + return of(ImportExportActions.importFail()); + } + const conversationId = + cleanConversations[firstConversationIndex].id; + const conversationFromState = + ConversationsSelectors.selectConversation( + state$.value, + conversationId, + ); + if (conversationFromState) { + return of(ImportExportActions.resetState()); + } + const foldersLocal = selectFolders(state$.value); + const folders = Array.from( + new Set([...foldersLocal, ...cleanFolders]), + ); + + const attachments = getUniqueAttachments( + getConversationAttachmentWithPath( + cleanConversations[firstConversationIndex], + folders, + ), + ); + + if (!attachments.length) { + return of( + ImportExportActions.importConversations({ + data: cleanHistory, + }), + ); + } + + return from( + getUnZipAttachments({ attachments, preUnzipedHistory }), + ).pipe( + switchMap((attachmentsToUpload) => { + if (!attachmentsToUpload.length) { + return of(ImportExportActions.importFail()); + } + return of( + ImportExportActions.uploadConversationAttachments({ + attachmentsToUpload, + completeHistory: cleanHistory, + }), + ); + }), + ); + }), + ); + }), + ); + }), + ); + +const uploadConversationAttachmentsEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.uploadConversationAttachments.match), + switchMap(({ payload }) => { + const { attachmentsToUpload, completeHistory } = payload; + const bucket = FilesSelectors.selectBucket(state$.value); + + if (!bucket.length) { + return of(ImportExportActions.importFail()); + } + const conversation = completeHistory.history[firstConversationIndex]; + + const actions = attachmentsToUpload.map((attachment) => { + const formData = new FormData(); + if (!attachment.fileContent) { + return of( + ImportExportActions.uploadSingleFileFail({ + id: attachment.id, + }), + ); + } + formData.append('attachment', attachment.fileContent, attachment.name); + const relativePath = `imports/${conversation.id}/${attachment.relativePath}`; + return DataService.sendFile( + formData, + bucket, + relativePath, + attachment.name, + ).pipe( + filter( + ({ percent, result }) => + typeof percent !== 'undefined' || typeof result !== 'undefined', + ), + map(({ percent, result }) => { + if (result) { + const { + id, + name, + absolutePath, + relativePath, + status, + percent, + contentType, + } = result; + return ImportExportActions.uploadSingleAttachmentSuccess({ + apiResult: { + id, + name, + absolutePath, + relativePath, + status, + percent, + contentType, + }, + }); + } + + return FilesActions.uploadFileTick({ + id: attachment.id, + percent: percent!, + }); + }), + catchError(() => { + return of( + ImportExportActions.uploadSingleFileFail({ + id: attachment.id, + }), + ); + }), + ); + }); + mergeAll(5); + return concat(...actions).pipe( + takeUntil(action$.pipe(filter(ImportExportActions.importStop.match))), + ); + }), + ); + +const uploadAllAttachmentsSuccessEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.uploadSingleAttachmentSuccess.match), + map(() => ({ + attachmentsToUpload: ImportExportSelectors.selectAttachmentsIdsToUpload( + state$.value, + ), + uploadedAttachments: ImportExportSelectors.selectUploadedAttachments( + state$.value, + ), + loadedHistory: ImportExportSelectors.selectImportedHistory(state$.value), + attachmentsErrors: ImportExportSelectors.selectAttachmentsErrors( + state$.value, + ), + })), + switchMap((payload) => { + const { + attachmentsToUpload, + uploadedAttachments, + loadedHistory, + attachmentsErrors, + } = payload; + + if (!uploadedAttachments.length) { + return of(ImportExportActions.importFail()); + } + + const allUploadedAmount = + uploadedAttachments.length + attachmentsErrors.length; + + if ( + attachmentsToUpload.length && + attachmentsToUpload.length !== uploadedAttachments.length && + attachmentsToUpload.length === allUploadedAmount + ) { + return of(ImportExportActions.importFail()); + } + + if ( + attachmentsToUpload.length && + attachmentsToUpload.length === uploadedAttachments.length + ) { + const updatedMessages: Message[] = loadedHistory.history[ + firstConversationIndex + ].messages.map((message) => { + if (!message.custom_content?.attachments) { + return message; + } + + const newAttachments = message.custom_content.attachments.map( + (oldAttachment) => { + if (!oldAttachment.url) { + return oldAttachment; + } + + const regExpForOldAttachmentId = /^files\/\w*\//; + const indexAfterSplit = 1; + const oldAttachmentId = decodeURI(oldAttachment.url).split( + regExpForOldAttachmentId, + )[indexAfterSplit]; + + const newAttachmentFile = uploadedAttachments.find( + (newAttachment) => { + if (!newAttachment.id) { + return; + } + const regExpForNewAttachmentId = /^imports\/[\w-]*\//; + const newAttachmentId = newAttachment.id.split( + regExpForNewAttachmentId, + )[indexAfterSplit]; + return newAttachmentId === oldAttachmentId; + }, + ); + + if ( + !newAttachmentFile || + !newAttachmentFile.contentType || + !newAttachmentFile.name + ) { + return oldAttachment; + } + + return { + ...oldAttachment, + url: encodeURI( + `${newAttachmentFile.absolutePath}/${newAttachmentFile.name}`, + ), + }; + }, + ); + + const newCustomContent: Message['custom_content'] = { + ...message.custom_content, + attachments: newAttachments, + }; + return { + ...message, + custom_content: newCustomContent, + }; + }); + + const updatedConversation: Conversation = { + ...loadedHistory.history[firstConversationIndex], + messages: updatedMessages, + }; + return of( + ImportExportActions.importConversations({ + data: { ...loadedHistory, history: [updatedConversation] }, + }), + ); + } + return EMPTY; + }), + ); + +const checkImportFailEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ImportExportActions.uploadSingleFileFail.match), + map(() => ({ + attachmentsErrors: ImportExportSelectors.selectAttachmentsErrors( + state$.value, + ), + attachmentsToUpload: ImportExportSelectors.selectAttachmentsIdsToUpload( + state$.value, + ), + })), + switchMap(({ attachmentsErrors, attachmentsToUpload }) => { + if ( + attachmentsErrors.length && + attachmentsToUpload.length === attachmentsErrors.length + ) { + return of(ImportExportActions.importFail()); + } + return EMPTY; + }), + ); + +const importFailEpic: AppEpic = (action$) => + action$.pipe( + filter(ImportExportActions.importFail.match), + tap(() => { + toast.error(errorsMessages.importFailed); + }), + ignoreElements(), + ); + +const exportFailEpic: AppEpic = (action$) => + action$.pipe( + filter(ImportExportActions.exportFail.match), + tap(() => { + toast.error(errorsMessages.exportFailed); + }), + ignoreElements(), + ); + +const resetStateEpic: AppEpic = (action$) => + action$.pipe( + filter( + (action) => + ImportExportActions.exportCancel.match(action) || + ImportExportActions.exportConversationSuccess.match(action) || + ImportExportActions.exportFail.match(action) || + ImportExportActions.importStop.match(action) || + ImportExportActions.importConversationsSuccess.match(action) || + ImportExportActions.importFail.match(action), + ), + switchMap(() => { + return of(ImportExportActions.resetState()); + }), + ); + +export const ImportExportEpics = combineEpics( + exportConversationEpic, + exportConversationsEpic, + importConversationsEpic, + importZipEpic, + uploadConversationAttachmentsEpic, + uploadAllAttachmentsSuccessEpic, + resetStateEpic, + importFailEpic, + exportFailEpic, + checkImportFailEpic, +); diff --git a/apps/chat/src/store/import-export/importExport.reducers.ts b/apps/chat/src/store/import-export/importExport.reducers.ts new file mode 100644 index 0000000000..95c4bed8cd --- /dev/null +++ b/apps/chat/src/store/import-export/importExport.reducers.ts @@ -0,0 +1,161 @@ +import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit'; + +import { DialFile, Status } from '@/src/types/files'; +import { + LatestExportFormat, + Operation, + SupportedExportFormats, +} from '@/src/types/importExport'; + +import { RootState } from '..'; + +type UploadedAttachment = Partial; + +export type AttachmentToUpload = DialFile; + +interface ImportExportState { + attachmentsIdsToUpload: string[]; + uploadedAttachments: UploadedAttachment[]; + importedHistory: LatestExportFormat; + attachmentsErrors: string[]; + status?: Status; + operation?: Operation; +} +const defaultImportedHistory: LatestExportFormat = { + version: 4, + history: [], + folders: [], + prompts: [], +}; +const initialState: ImportExportState = { + attachmentsIdsToUpload: [], + uploadedAttachments: [], + importedHistory: defaultImportedHistory, + attachmentsErrors: [], +}; + +export const importExportSlice = createSlice({ + name: 'importExport', + initialState, + reducers: { + resetState: (state) => { + state.status = undefined; + state.attachmentsIdsToUpload = []; + state.uploadedAttachments = []; + state.importedHistory = defaultImportedHistory; + state.attachmentsErrors = []; + state.operation = undefined; + }, + exportConversation: ( + state, + _action: PayloadAction<{ + conversationId: string; + withAttachments?: boolean; + }>, + ) => { + state.status = 'LOADING'; + state.operation = Operation.Exporting; + }, + exportConversationSuccess: (state) => state, + exportConversations: (state) => state, + exportCancel: (state) => state, + exportFail: (state) => { + state.status = undefined; + }, + importConversations: ( + state, + _action: PayloadAction<{ data: SupportedExportFormats }>, + ) => { + state.status = 'LOADING'; + state.operation = Operation.Importing; + }, + importZipConversations: ( + state, + _action: PayloadAction<{ zipFile: File }>, + ) => { + state.status = 'LOADING'; + state.operation = Operation.Importing; + }, + importStop: (state) => state, + importConversationsSuccess: (state) => state, + importFail: (state) => state, + uploadConversationAttachments: ( + state, + { + payload, + }: PayloadAction<{ + attachmentsToUpload: AttachmentToUpload[]; + completeHistory: LatestExportFormat; + }>, + ) => { + state.attachmentsIdsToUpload = payload.attachmentsToUpload.map( + ({ id }) => id, + ); + state.importedHistory = payload.completeHistory; + }, + uploadSingleAttachmentSuccess: ( + state, + { + payload, + }: PayloadAction<{ + apiResult: UploadedAttachment; + }>, + ) => { + state.uploadedAttachments = state.uploadedAttachments.concat( + payload.apiResult, + ); + }, + uploadSingleFileFail: ( + state, + { + payload, + }: PayloadAction<{ + id: string; + }>, + ) => { + state.attachmentsErrors = state.attachmentsErrors.concat(payload.id); + }, + }, +}); + +const rootSelector = (state: RootState): ImportExportState => + state.importExport; + +const selectAttachmentsIdsToUpload = createSelector([rootSelector], (state) => { + return state.attachmentsIdsToUpload; +}); +const selectUploadedAttachments = createSelector([rootSelector], (state) => { + return state.uploadedAttachments; +}); + +const selectAttachmentsErrors = createSelector([rootSelector], (state) => { + return state.attachmentsErrors; +}); + +const selectImportedHistory = createSelector([rootSelector], (state) => { + return state.importedHistory; +}); + +const selectImportStatus = createSelector([rootSelector], (state) => { + return state.status; +}); + +const selectOperationName = createSelector([rootSelector], (state) => { + return state.operation; +}); + +const selectIsLoadingImportExport = createSelector([rootSelector], (state) => { + return state.status === 'LOADING'; +}); + +export const ImportExportSelectors = { + selectAttachmentsIdsToUpload, + selectUploadedAttachments, + selectAttachmentsErrors, + selectImportedHistory, + selectImportStatus, + selectOperationName, + selectIsLoadingImportExport, +}; + +export const ImportExportActions = importExportSlice.actions; diff --git a/apps/chat/src/store/index.ts b/apps/chat/src/store/index.ts index 853bf96daa..35b1b75d9b 100644 --- a/apps/chat/src/store/index.ts +++ b/apps/chat/src/store/index.ts @@ -15,6 +15,8 @@ import { ConversationsEpics } from './conversations/conversations.epics'; import { conversationsSlice } from './conversations/conversations.reducers'; import { FilesEpics } from './files/files.epics'; import { filesSlice } from './files/files.reducers'; +import { ImportExportEpics } from './import-export/importExport.epics'; +import { importExportSlice } from './import-export/importExport.reducers'; import { ModelsEpics } from './models/models.epics'; import { modelsSlice } from './models/models.reducers'; import { OverlayEpics } from './overlay/overlay.epics'; @@ -35,6 +37,7 @@ export const rootEpic = combineEpics( OverlayEpics, SettingsEpics, FilesEpics, + ImportExportEpics, ); const reducer = { @@ -47,6 +50,7 @@ const reducer = { overlay: overlaySlice.reducer, files: filesSlice.reducer, auth: authSlice.reducer, + importExport: importExportSlice.reducer, }; const getMiddleware = ( //eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/chat/src/store/prompts/prompts.reducers.ts b/apps/chat/src/store/prompts/prompts.reducers.ts index dce0a53bc6..35e742958d 100644 --- a/apps/chat/src/store/prompts/prompts.reducers.ts +++ b/apps/chat/src/store/prompts/prompts.reducers.ts @@ -3,8 +3,8 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { generateNextName, getNextDefaultName } from '@/src/utils/app/folders'; import { translate } from '@/src/utils/app/translation'; -import { PromptsHistory } from '@/src/types/export'; import { FolderInterface, FolderType } from '@/src/types/folder'; +import { PromptsHistory } from '@/src/types/importExport'; import { Prompt } from '@/src/types/prompt'; import { SearchFilters } from '@/src/types/search'; import { PublishRequest } from '@/src/types/share'; diff --git a/apps/chat/src/types/files.ts b/apps/chat/src/types/files.ts index 734d7ebb00..14af6955d3 100644 --- a/apps/chat/src/types/files.ts +++ b/apps/chat/src/types/files.ts @@ -56,3 +56,5 @@ export type FileFolderInterface = FolderInterface & { absolutePath?: string; relativePath?: string; }; + +export type Status = undefined | 'LOADING' | 'LOADED' | 'FAILED'; diff --git a/apps/chat/src/types/export.ts b/apps/chat/src/types/importExport.ts similarity index 95% rename from apps/chat/src/types/export.ts rename to apps/chat/src/types/importExport.ts index 60af86b477..d5ca0f5a60 100644 --- a/apps/chat/src/types/export.ts +++ b/apps/chat/src/types/importExport.ts @@ -57,3 +57,8 @@ export interface PromptsHistory { prompts: Prompt[]; folders: FolderInterface[]; } + +export enum Operation { + Importing = 'Importing', + Exporting = 'Exporting', +} diff --git a/apps/chat/src/utils/app/__tests__/importExports.test.ts b/apps/chat/src/utils/app/__tests__/importExports.test.ts index 16cb056860..75f512f5af 100644 --- a/apps/chat/src/utils/app/__tests__/importExports.test.ts +++ b/apps/chat/src/utils/app/__tests__/importExports.test.ts @@ -13,13 +13,13 @@ import { import { Conversation, Message, Role } from '@/src/types/chat'; import { EntityType } from '@/src/types/common'; +import { FolderType } from '@/src/types/folder'; import { ExportFormatV1, ExportFormatV2, ExportFormatV4, PromptsHistory, -} from '@/src/types/export'; -import { FolderType } from '@/src/types/folder'; +} from '@/src/types/importExport'; import { OpenAIEntityModelID } from '@/src/types/openai'; import { @@ -221,7 +221,8 @@ describe('cleanData Functions', () => { describe('Export helpers functions', () => { it('Should return false for non-prompts data', () => { const testData = [{ id: 1 }]; - expect(isPromtsFormat(testData as any)).toBeFalsy(); + + expect(isPromtsFormat(testData as unknown as PromptsHistory)).toBeFalsy(); }); it('Should return true for prompts data', () => { diff --git a/apps/chat/src/utils/app/import-export.ts b/apps/chat/src/utils/app/import-export.ts index 0a1273d4d4..9b96990d3f 100644 --- a/apps/chat/src/utils/app/import-export.ts +++ b/apps/chat/src/utils/app/import-export.ts @@ -1,4 +1,5 @@ import { Conversation } from '@/src/types/chat'; +import { FolderInterface, FolderType } from '@/src/types/folder'; import { ExportConversationsFormatV4, ExportFormatV1, @@ -8,8 +9,7 @@ import { LatestExportFormat, PromptsHistory, SupportedExportFormats, -} from '@/src/types/export'; -import { FolderInterface, FolderType } from '@/src/types/folder'; +} from '@/src/types/importExport'; import { Prompt } from '@/src/types/prompt'; import { cleanConversationHistory } from './clean'; @@ -122,7 +122,7 @@ function downloadChatPromptData( }); const url = URL.createObjectURL(blob); - triggerDownload(url, `chatbot_ui_${exportType}_${currentDate()}.json`); + triggerDownload(url, `ai_dial_chat_${exportType}_${currentDate()}.json`); } const triggerDownloadConversation = (data: ExportConversationsFormatV4) => { @@ -155,6 +155,23 @@ export const exportConversation = ( triggerDownloadConversation(data); }; +interface PrepareConversationsForExport { + conversations: Conversation[]; + folders: FolderInterface[]; +} +export const prepareConversationsForExport = ({ + conversations, + folders, +}: PrepareConversationsForExport) => { + const data = { + version: 4, + history: conversations || [], + folders: folders || [], + } as ExportConversationsFormatV4; + + return data; +}; + export const exportConversations = ( conversations: Conversation[], folders: FolderInterface[], diff --git a/apps/chat/src/utils/app/zip-import-export.ts b/apps/chat/src/utils/app/zip-import-export.ts new file mode 100644 index 0000000000..a7c8300f60 --- /dev/null +++ b/apps/chat/src/utils/app/zip-import-export.ts @@ -0,0 +1,162 @@ +import { Conversation } from '@/src/types/chat'; +import { DialFile } from '@/src/types/files'; +import { FolderInterface } from '@/src/types/folder'; + +import { AttachmentToUpload } from '@/src/store/import-export/importExport.reducers'; + +import { constructPath, triggerDownload } from './file'; +import { prepareConversationsForExport } from './import-export'; + +import JSZip from 'jszip'; + +interface GetZippedFile { + files: DialFile[]; + conversations: Conversation[]; + folders: FolderInterface[]; +} + +const getAttachmentFromApi = async (file: DialFile) => { + const fileResult = await fetch( + `api/files/file/${constructPath(file.absolutePath, file.name)}`, + ); + return fileResult.blob(); +}; + +export async function getZippedFile({ + files, + conversations, + folders, +}: GetZippedFile) { + const zip = new JSZip(); + files.forEach((file) => { + const fileBlob = getAttachmentFromApi(file); + + zip.file(`res/${file.id}`, fileBlob); + }); + + const history = prepareConversationsForExport({ conversations, folders }); + const jsonHistory = JSON.stringify(history, null, 2); + zip.file(`conversations/conversations_history.json`, jsonHistory); + + const content = await zip.generateAsync({ type: 'base64' }); + return content; +} + +export const downloadExportZip = (content: string) => { + triggerDownload( + 'data:application/zip;base64,' + content, + 'ai_dial_chat_with_attachments.zip', + ); +}; + +export interface PreUnZipedHistory { + zip: JSZip; + history: JSZip.JSZipObject; + res: { relativePath: string; zipEntry: JSZip.JSZipObject }[]; +} +export async function importZippedHistory(zipFile: File) { + const zip = await JSZip.loadAsync(zipFile); + const chatsLib = {} as PreUnZipedHistory; + chatsLib.res = []; + const regExpConversationsFolder = 'conversations/*'; + const regExpConversationsHistory = /\.json$/i; + const regExpResFolder = 'res/*'; + const regExpRes = /^.+\..+$/; + + zip.forEach((relativePath, zipEntry) => { + if ( + relativePath.match(regExpConversationsFolder) && + relativePath.match(regExpConversationsHistory) + ) { + chatsLib.history = zipEntry; + } + + if (relativePath.match(regExpResFolder) && relativePath.match(regExpRes)) { + chatsLib.res.push({ relativePath, zipEntry }); + } + }); + chatsLib.zip = zip; + + return chatsLib; +} + +const searchSubstring = 'res/'; +const substringLength = searchSubstring.length; + +const getFirstSlashIndex = (relativePath: string) => { + return relativePath.indexOf('res/'); +}; + +export const getUnZipAttachments = async ({ + attachments, + preUnzipedHistory, +}: { + attachments: Partial[]; + preUnzipedHistory: PreUnZipedHistory; +}) => { + const getAllAttachments = attachments.map(async (attachment) => { + const fileToUpload = preUnzipedHistory.res.find(({ relativePath }) => { + const fileId = relativePath.slice( + getFirstSlashIndex(relativePath) + substringLength, + ); + + return fileId === attachment.id; + }); + + if (!fileToUpload) { + return; + } + + const { relativePath, zipEntry } = fileToUpload; + const { zip } = preUnzipedHistory; + const file = zip.file(zipEntry.name); + + if (!file) { + return; + } + const fileContent = await file.async('blob'); + + if (!fileContent) { + return; + } + + const firstSlashIndex = getFirstSlashIndex(relativePath); + const lastSlashIndex = zipEntry.name.lastIndexOf('/'); + + const fileName = zipEntry.name.slice(lastSlashIndex + 1); + const fileRelativePath = relativePath.slice( + firstSlashIndex + substringLength, + lastSlashIndex, + ); + const fileId = relativePath.slice(firstSlashIndex + substringLength); + + const attachmentToUpload = { + fileContent, + id: fileId, + relativePath: fileRelativePath, + name: fileName, + }; + return attachmentToUpload; + }); + + const attachmentsToUpload = await Promise.all(getAllAttachments); + + return attachmentsToUpload.filter(Boolean) as AttachmentToUpload[]; +}; + +export const compressConversationInZip = async ({ + attachments, + conversation, + parentFolders, +}: { + attachments: DialFile[]; + conversation: Conversation; + parentFolders: FolderInterface[]; +}) => { + const content = await getZippedFile({ + files: attachments, + conversations: [conversation], + folders: parentFolders, + }); + return content; +}; diff --git a/apps/chat/tailwind.config.js b/apps/chat/tailwind.config.js index f780b30fb5..8776b23f35 100644 --- a/apps/chat/tailwind.config.js +++ b/apps/chat/tailwind.config.js @@ -58,6 +58,9 @@ module.exports = { gradientColorStops: commonBgColors, ///////// extend: { + animation: { + 'spin-steps': 'spin 0.75s steps(8, end) infinite', + }, colors: { transparent: 'transparent', }, diff --git a/package-lock.json b/package-lock.json index 105a5e37e6..aa6e52d923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "eventsource-parser": "^0.1.0", "i18next": "^22.4.13", "isomorphic-dompurify": "^1.8.0", + "jszip": "^3.10.1", "mime-types": "^2.1.35", "next": "13.5.4", "next-auth": "^4.24.5", @@ -9838,6 +9839,11 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -13059,6 +13065,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -14977,6 +14988,44 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15039,6 +15088,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -16999,6 +17056,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -18338,6 +18400,11 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/process-warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", @@ -19592,6 +19659,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", diff --git a/package.json b/package.json index 20fcbb21ed..826449384f 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "eventsource-parser": "^0.1.0", "i18next": "^22.4.13", "isomorphic-dompurify": "^1.8.0", + "jszip": "^3.10.1", "mime-types": "^2.1.35", "next": "13.5.4", "next-auth": "^4.24.5",