-
+ {areEntitiesUploaded ? (
+ <>
+
- {actionButtons}
+ {actionButtons}
-
- {folderComponent}
+
+ {folderComponent}
- {filteredItems?.length > 0 ? (
-
0 ? (
+
{
+ setIsDraggingOver(false);
+ handleDrop(e);
+ }}
+ onDragOver={allowDrop}
+ onDragEnter={highlightDrop}
+ onDragLeave={removeHighlight}
+ data-qa="draggable-area"
+ >
+ {itemComponent}
+
+ ) : searchTerm.length ? (
+
+
+
+ ) : (
+
+
+
)}
- onDrop={(e) => {
- setIsDraggingOver(false);
- handleDrop(e);
- }}
- onDragOver={allowDrop}
- onDragEnter={highlightDrop}
- onDragLeave={removeHighlight}
- data-qa="draggable-area"
- >
- {itemComponent}
- ) : searchTerm.length ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {footerComponent}
+ {footerComponent}
+ >
+ ) : (
+
+ )}
) : null;
diff --git a/apps/chat/src/constants/default-settings.ts b/apps/chat/src/constants/default-settings.ts
index b90934fb51..c7b10db623 100644
--- a/apps/chat/src/constants/default-settings.ts
+++ b/apps/chat/src/constants/default-settings.ts
@@ -10,6 +10,8 @@ export const DEFAULT_TEMPERATURE = parseFloat(
);
export const DEFAULT_CONVERSATION_NAME = 'New conversation';
+export const DEFAULT_FOLDER_NAME = 'New folder';
+export const EMPTY_MODEL_ID = 'empty';
export const DIAL_API_VERSION =
process.env.DIAL_API_VERSION || '2023-03-15-preview';
diff --git a/apps/chat/src/constants/errors.ts b/apps/chat/src/constants/errors.ts
index 9ed1438ecb..5e1b45a892 100644
--- a/apps/chat/src/constants/errors.ts
+++ b/apps/chat/src/constants/errors.ts
@@ -16,12 +16,14 @@ export const errorsMessages = {
'Server is taking to long to respond due to either poor internet connection or excessive load. Please check your internet connection and try again. You also may try different model.',
customThemesConfigNotProvided:
'The custom config host url not provided. Please recheck application settings',
- errorDuringFileRequest:
- 'Error happened during file request. Please try again later.',
+ errorDuringEntityRequest: (entityType: string) =>
+ `Error happened during ${entityType} request. Please try again later.`,
errorGettingUserFileBucket:
'Error happened during getting file user bucket. Please reload the page to being able to load files.',
noModelsAvailable:
'You do not have any available models. Please contact your administrator or try to reload the page.',
importFailed: 'Import failed',
exportFailed: 'Export failed',
+ notValidEntityType:
+ 'You made a request with an unavailable or nonexistent entity type',
};
diff --git a/apps/chat/src/hooks/usePromptSelection.ts b/apps/chat/src/hooks/usePromptSelection.ts
index 9a65312b0d..cac3d387eb 100644
--- a/apps/chat/src/hooks/usePromptSelection.ts
+++ b/apps/chat/src/hooks/usePromptSelection.ts
@@ -1,9 +1,20 @@
-import { KeyboardEvent, useCallback, useMemo, useState } from 'react';
+import {
+ KeyboardEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useDispatch } from 'react-redux';
import { Prompt } from '@/src/types/prompt';
import { useAppSelector } from '@/src/store/hooks';
-import { PromptsSelectors } from '@/src/store/prompts/prompts.reducers';
+import {
+ PromptsActions,
+ PromptsSelectors,
+} from '@/src/store/prompts/prompts.reducers';
/**
* Custom hook for managing prompt selection in a chat interface.
@@ -13,12 +24,17 @@ import { PromptsSelectors } from '@/src/store/prompts/prompts.reducers';
export const usePromptSelection = (maxLength: number) => {
const prompts = useAppSelector(PromptsSelectors.selectPrompts);
- const [promptInputValue, setPromptInputValue] = useState('');
+ const dispatch = useDispatch();
+
+ const isLoading = useAppSelector(PromptsSelectors.isPromptLoading);
+
const [activePromptIndex, setActivePromptIndex] = useState(0);
+ const [promptInputValue, setPromptInputValue] = useState('');
const [content, setContent] = useState
('');
const [isPromptLimitModalOpen, setIsPromptLimitModalOpen] = useState(false);
const [showPromptList, setShowPromptList] = useState(false);
const [variables, setVariables] = useState([]);
+ const [isRequestSent, setIsRequestSent] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const filteredPrompts = useMemo(
@@ -29,6 +45,10 @@ export const usePromptSelection = (maxLength: number) => {
[prompts, promptInputValue],
);
+ const selectedPromptRef = useRef(
+ filteredPrompts[0] ? filteredPrompts[0] : undefined,
+ );
+
/**
* Updates the visibility of the prompt list based on the user's input text.
* @param text The text entered by the user.
@@ -92,14 +112,13 @@ export const usePromptSelection = (maxLength: number) => {
* Checks if the selected prompt content is within the maximum length and updates the content state.
*/
const handleInitModal = useCallback(() => {
- const selectedPrompt = filteredPrompts[activePromptIndex]
- ? filteredPrompts[activePromptIndex]
- : undefined;
+ const selectedPrompt = selectedPromptRef.current as Prompt | undefined;
if (!selectedPrompt?.content) {
setShowPromptList(false);
return;
}
+
if (selectedPrompt.content.length > maxLength) {
setIsPromptLimitModalOpen(true);
return;
@@ -110,7 +129,28 @@ export const usePromptSelection = (maxLength: number) => {
);
handlePromptSelect(selectedPrompt);
setShowPromptList(false);
- }, [activePromptIndex, filteredPrompts, handlePromptSelect, maxLength]);
+ }, [handlePromptSelect, maxLength]);
+
+ /**
+ * Resets the request sending state and update the currently selected prompt,
+ * then call the modal window initialization function.
+ */
+ useEffect(() => {
+ if (!isLoading && isRequestSent) {
+ setIsRequestSent(false);
+ selectedPromptRef.current = filteredPrompts[activePromptIndex]
+ ? filteredPrompts[activePromptIndex]
+ : undefined;
+
+ handleInitModal();
+ }
+ }, [
+ activePromptIndex,
+ filteredPrompts,
+ handleInitModal,
+ isLoading,
+ isRequestSent,
+ ]);
/**
* Handles key down events when the prompt list is shown.
@@ -136,7 +176,12 @@ export const usePromptSelection = (maxLength: number) => {
);
} else if (e.key === 'Enter') {
e.preventDefault();
- handleInitModal();
+ setIsRequestSent(true);
+ dispatch(
+ PromptsActions.uploadPrompt({
+ promptId: filteredPrompts[activePromptIndex].id,
+ }),
+ );
} else if (e.key === 'Escape') {
e.preventDefault();
setShowPromptList(false);
@@ -144,9 +189,21 @@ export const usePromptSelection = (maxLength: number) => {
setActivePromptIndex(0);
}
},
- [handleInitModal, prompts.length, setActivePromptIndex, setShowPromptList],
+ [activePromptIndex, dispatch, filteredPrompts, prompts.length],
);
+ /**
+ * Initializes the prompt loads.
+ */
+ const getPrompt = useCallback(() => {
+ setIsRequestSent(true);
+ dispatch(
+ PromptsActions.uploadPrompt({
+ promptId: filteredPrompts[activePromptIndex].id,
+ }),
+ );
+ }, [activePromptIndex, dispatch, filteredPrompts]);
+
return {
setActivePromptIndex,
activePromptIndex,
@@ -159,9 +216,11 @@ export const usePromptSelection = (maxLength: number) => {
isModalVisible,
content,
updatePromptListVisibility,
- handleInitModal,
filteredPrompts,
variables,
handleKeyDownIfShown,
+ isRequestSent,
+ getPrompt,
+ isLoading: isLoading && isRequestSent,
};
};
diff --git a/apps/chat/src/pages/api/files/file/[...slug].ts b/apps/chat/src/pages/api/[entitytype]/[...slug].ts
similarity index 75%
rename from apps/chat/src/pages/api/files/file/[...slug].ts
rename to apps/chat/src/pages/api/[entitytype]/[...slug].ts
index d11d96fd9b..40e7129205 100644
--- a/apps/chat/src/pages/api/files/file/[...slug].ts
+++ b/apps/chat/src/pages/api/[entitytype]/[...slug].ts
@@ -4,17 +4,27 @@ import { JWT, getToken } from 'next-auth/jwt';
import { validateServerSession } from '@/src/utils/auth/session';
import { OpenAIError } from '@/src/utils/server';
+import {
+ getEntityTypeFromPath,
+ getEntityUrlFromSlugs,
+ isValidEntityApiType,
+} from '@/src/utils/server/api';
import { getApiHeaders } from '@/src/utils/server/get-headers';
import { logger } from '@/src/utils/server/logger';
import { errorsMessages } from '@/src/constants/errors';
-import { authOptions } from '../../auth/[...nextauth]';
+import { authOptions } from '@/src/pages/api/auth/[...nextauth]';
import fetch from 'node-fetch';
import { Readable } from 'stream';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const entityType = getEntityTypeFromPath(req);
+ if (!entityType || !isValidEntityApiType(entityType)) {
+ return res.status(400).json(errorsMessages.notValidEntityType);
+ }
+
const session = await getServerSession(req, res, authOptions);
const isSessionValid = validateServerSession(session, req, res);
const token = await getToken({ req });
@@ -25,7 +35,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
if (req.method === 'GET') {
return await handleGetRequest(req, token, res);
- } else if (req.method === 'PUT') {
+ } else if (req.method === 'PUT' || req.method === 'POST') {
return await handlePutRequest(req, token, res);
} else if (req.method === 'DELETE') {
return await handleDeleteRequest(req, token, res);
@@ -38,7 +48,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
.status(parseInt(error.code, 10) || 500)
.send(error.message || errorsMessages.generalServer);
}
- return res.status(500).send(errorsMessages.errorDuringFileRequest);
+ return res
+ .status(500)
+ .send(errorsMessages.errorDuringEntityRequest(entityType));
}
};
@@ -57,14 +69,7 @@ async function handlePutRequest(
res: NextApiResponse,
) {
const readable = Readable.from(req);
- const slugs = Array.isArray(req.query.slug)
- ? req.query.slug
- : [req.query.slug];
-
- if (!slugs || slugs.length === 0) {
- throw new OpenAIError('No file path provided', '', '', '400');
- }
- const url = `${process.env.DIAL_API_HOST}/v1/${encodeURI(slugs.join('/'))}`;
+ const url = getEntityUrlFromSlugs(process.env.DIAL_API_HOST, req);
const proxyRes = await fetch(url, {
method: 'PUT',
headers: {
@@ -92,14 +97,7 @@ async function handleGetRequest(
token: JWT | null,
res: NextApiResponse,
) {
- const slugs = Array.isArray(req.query.slug)
- ? req.query.slug
- : [req.query.slug];
-
- if (!slugs || slugs.length === 0) {
- throw new OpenAIError('No file path provided', '', '', '400');
- }
- const url = `${process.env.DIAL_API_HOST}/v1/${encodeURI(slugs.join('/'))}`;
+ const url = getEntityUrlFromSlugs(process.env.DIAL_API_HOST, req);
const proxyRes = await fetch(url, {
headers: getApiHeaders({ jwt: token?.access_token as string }),
});
@@ -123,15 +121,7 @@ async function handleDeleteRequest(
token: JWT | null,
res: NextApiResponse,
) {
- const slugs = Array.isArray(req.query.slug)
- ? req.query.slug
- : [req.query.slug];
-
- if (!slugs || slugs.length === 0) {
- throw new OpenAIError('No file path provided', '', '', '400');
- }
- const url = `${process.env.DIAL_API_HOST}/v1/${encodeURI(slugs.join('/'))}`;
-
+ const url = getEntityUrlFromSlugs(process.env.DIAL_API_HOST, req);
const proxyRes = await fetch(url, {
method: 'DELETE',
headers: getApiHeaders({ jwt: token?.access_token as string }),
diff --git a/apps/chat/src/pages/api/[entitytype]/listing.ts b/apps/chat/src/pages/api/[entitytype]/listing.ts
new file mode 100644
index 0000000000..021037f6ff
--- /dev/null
+++ b/apps/chat/src/pages/api/[entitytype]/listing.ts
@@ -0,0 +1,92 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+import { getToken } from 'next-auth/jwt';
+import { getServerSession } from 'next-auth/next';
+
+import { validateServerSession } from '@/src/utils/auth/session';
+import { OpenAIError } from '@/src/utils/server';
+import {
+ ApiKeys,
+ getEntityTypeFromPath,
+ isValidEntityApiType,
+} from '@/src/utils/server/api';
+import { getApiHeaders } from '@/src/utils/server/get-headers';
+import { logger } from '@/src/utils/server/logger';
+
+import {
+ BackendChatEntity,
+ BackendChatFolder,
+ BackendDataNodeType,
+} from '@/src/types/common';
+import { BackendFile, BackendFileFolder } from '@/src/types/files';
+
+import { errorsMessages } from '@/src/constants/errors';
+
+import { authOptions } from '@/src/pages/api/auth/[...nextauth]';
+
+import fetch from 'node-fetch';
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const entityType = getEntityTypeFromPath(req);
+ if (!entityType || !isValidEntityApiType(entityType)) {
+ return res.status(400).json(errorsMessages.notValidEntityType);
+ }
+
+ const session = await getServerSession(req, res, authOptions);
+ const isSessionValid = validateServerSession(session, req, res);
+ if (!isSessionValid) {
+ return;
+ }
+
+ try {
+ const {
+ path = '',
+ filter,
+ bucket,
+ recursive = false,
+ } = req.query as {
+ path: string;
+ filter?: BackendDataNodeType;
+ bucket: string;
+ recursive?: string;
+ };
+
+ const token = await getToken({ req });
+
+ const url = `${
+ process.env.DIAL_API_HOST
+ }/v1/metadata/${entityType}/${bucket}${path && `/${encodeURI(path)}`}/?limit=1000${recursive ? '&recursive=true' : ''}`;
+
+ const response = await fetch(url, {
+ headers: getApiHeaders({ jwt: token?.access_token as string }),
+ });
+
+ if (response.status === 404) {
+ return res.status(200).send([]);
+ } else if (!response.ok) {
+ const serverErrorMessage = await response.text();
+ throw new OpenAIError(serverErrorMessage, '', '', response.status + '');
+ }
+
+ const json = (await response.json()) as
+ | BackendFileFolder
+ | BackendChatFolder;
+ let result: (
+ | BackendFile
+ | BackendFileFolder
+ | BackendChatEntity
+ | BackendChatFolder
+ )[] = json.items || [];
+
+ const filterableEntityTypes: string[] = Object.values(ApiKeys);
+ if (filter && filterableEntityTypes.includes(entityType)) {
+ result = result.filter((item) => item.nodeType === filter);
+ }
+
+ return res.status(200).send(result);
+ } catch (error) {
+ logger.error(error);
+ return res.status(500).json(errorsMessages.generalServer);
+ }
+};
+
+export default handler;
diff --git a/apps/chat/src/pages/api/files/bucket.ts b/apps/chat/src/pages/api/bucket.ts
similarity index 90%
rename from apps/chat/src/pages/api/files/bucket.ts
rename to apps/chat/src/pages/api/bucket.ts
index b07135bada..a1cc05fd9a 100644
--- a/apps/chat/src/pages/api/files/bucket.ts
+++ b/apps/chat/src/pages/api/bucket.ts
@@ -2,14 +2,14 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { getServerSession } from 'next-auth/next';
-import { getApiHeaders } from '../../../utils/server/get-headers';
import { validateServerSession } from '@/src/utils/auth/session';
import { OpenAIError } from '@/src/utils/server';
+import { getApiHeaders } from '@/src/utils/server/get-headers';
import { logger } from '@/src/utils/server/logger';
import { errorsMessages } from '@/src/constants/errors';
-import { authOptions } from '../auth/[...nextauth]';
+import { authOptions } from '@/src/pages/api/auth/[...nextauth]';
import fetch from 'node-fetch';
diff --git a/apps/chat/src/pages/api/chat.ts b/apps/chat/src/pages/api/chat.ts
index 7f2553f4ba..330ae670c1 100644
--- a/apps/chat/src/pages/api/chat.ts
+++ b/apps/chat/src/pages/api/chat.ts
@@ -26,7 +26,6 @@ import tiktokenModel from '@dqbd/tiktoken/encoders/cl100k_base.json';
import { Tiktoken, init } from '@dqbd/tiktoken/lite/init';
import { readFileSync } from 'fs';
import path from 'path';
-import { validate } from 'uuid';
// export const config = {
// runtime: 'edge',
@@ -117,7 +116,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
id,
} = req.body as ChatBody;
- if (!id || !validate(id)) {
+ if (!id) {
return res.status(400).send(errorsMessages[400]);
}
diff --git a/apps/chat/src/pages/api/files/listing.ts b/apps/chat/src/pages/api/files/listing.ts
deleted file mode 100644
index 75e65c85a0..0000000000
--- a/apps/chat/src/pages/api/files/listing.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { NextApiRequest, NextApiResponse } from 'next';
-import { getToken } from 'next-auth/jwt';
-import { getServerSession } from 'next-auth/next';
-
-import { getApiHeaders } from '../../../utils/server/get-headers';
-import { validateServerSession } from '@/src/utils/auth/session';
-import { OpenAIError } from '@/src/utils/server';
-import { logger } from '@/src/utils/server/logger';
-
-import {
- BackendDataNodeType,
- BackendFile,
- BackendFileFolder,
-} from '@/src/types/files';
-
-import { errorsMessages } from '@/src/constants/errors';
-
-import { authOptions } from '../auth/[...nextauth]';
-
-import fetch from 'node-fetch';
-
-const handler = async (req: NextApiRequest, res: NextApiResponse) => {
- const session = await getServerSession(req, res, authOptions);
- const isSessionValid = validateServerSession(session, req, res);
- if (!isSessionValid) {
- return;
- }
-
- try {
- const {
- path = '',
- filter = '',
- bucket,
- } = req.query as {
- path: string;
- filter: BackendDataNodeType;
- bucket: string;
- };
-
- const token = await getToken({ req });
-
- const url = `${process.env.DIAL_API_HOST}/v1/metadata/files/${bucket}${
- path && `/${encodeURI(path)}`
- }/`;
-
- const response = await fetch(url, {
- headers: getApiHeaders({ jwt: token?.access_token as string }),
- });
-
- if (!response.ok) {
- const serverErrorMessage = await response.text();
- throw new OpenAIError(serverErrorMessage, '', '', response.status + '');
- }
-
- const json = (await response.json()) as BackendFileFolder;
- let result: (BackendFileFolder | BackendFile)[] = [];
- if (filter) {
- result = (json.items || []).filter((item) => item.nodeType === filter);
- }
-
- return res.status(200).send(result);
- } catch (error) {
- logger.error(error);
- return res.status(500).json(errorsMessages.generalServer);
- }
-};
-
-export default handler;
diff --git a/apps/chat/src/pages/api/rate.ts b/apps/chat/src/pages/api/rate.ts
index 6fd3812a86..e1b9341548 100644
--- a/apps/chat/src/pages/api/rate.ts
+++ b/apps/chat/src/pages/api/rate.ts
@@ -15,7 +15,6 @@ import { errorsMessages } from '@/src/constants/errors';
import { authOptions } from './auth/[...nextauth]';
import fetch from 'node-fetch';
-import { validate } from 'uuid';
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getServerSession(req, res, authOptions);
@@ -27,7 +26,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { responseId, modelId, value, id } = req.body as RateBody;
- if (!id || !validate(id) || !responseId || !modelId) {
+ if (!id || !responseId || !modelId) {
return res.status(400).send(errorsMessages[400]);
}
diff --git a/apps/chat/src/pages/index.tsx b/apps/chat/src/pages/index.tsx
index c753931f1d..5ed081f4ea 100644
--- a/apps/chat/src/pages/index.tsx
+++ b/apps/chat/src/pages/index.tsx
@@ -13,6 +13,7 @@ import { delay } from '@/src/utils/auth/delay';
import { isServerSessionValid } from '@/src/utils/auth/session';
import { timeoutAsync } from '@/src/utils/auth/timeout-async';
+import { StorageType } from '../types/storage';
import { Translation } from '../types/translation';
import { fallbackModelID } from '@/src/types/openai';
@@ -223,7 +224,11 @@ export const getServerSideProps: GetServerSideProps = async ({
packageJSON.version,
),
isAuthDisabled,
- storageType: process.env.STORAGE_TYPE || 'browserStorage',
+ storageType: Object.values(StorageType).includes(
+ process.env.STORAGE_TYPE as StorageType,
+ )
+ ? (process.env.STORAGE_TYPE as StorageType)
+ : StorageType.API,
announcement: process.env.ANNOUNCEMENT_HTML_MESSAGE || '',
themesHostDefined: !!process.env.THEMES_CONFIG_HOST,
};
diff --git a/apps/chat/src/store/conversations/conversations.epics.ts b/apps/chat/src/store/conversations/conversations.epics.ts
index de4085be91..75497064a2 100644
--- a/apps/chat/src/store/conversations/conversations.epics.ts
+++ b/apps/chat/src/store/conversations/conversations.epics.ts
@@ -22,6 +22,7 @@ import {
tap,
throwError,
timeout,
+ zip,
} from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
@@ -31,15 +32,29 @@ import { combineEpics } from 'redux-observable';
import { clearStateForMessages } from '@/src/utils/app/clear-messages-state';
import {
+ combineEntities,
+ updateEntitiesFoldersAndIds,
+} from '@/src/utils/app/common';
+import {
+ addGeneratedConversationId,
+ compareConversationsByDate,
+ getGeneratedConversationId,
getNewConversationName,
+ isChosenConversationValidForCompare,
isSettingsChanged,
+ parseConversationId,
} from '@/src/utils/app/conversation';
-import { DataService } from '@/src/utils/app/data/data-service';
-import { renameAttachments } from '@/src/utils/app/file';
+import { ConversationService } from '@/src/utils/app/data/conversation-service';
import {
- findRootFromItems,
- getFolderIdByPath,
- getTemporaryFoldersToPublish,
+ addGeneratedFolderId,
+ generateNextName,
+ getAllPathsFromId,
+ getAllPathsFromPath,
+ getFolderFromPath,
+ getFoldersFromPaths,
+ getNextDefaultName,
+ updateMovedEntityId,
+ updateMovedFolderId,
} from '@/src/utils/app/folders';
import {
mergeMessages,
@@ -57,24 +72,156 @@ import {
RateBody,
Role,
} from '@/src/types/chat';
-import { EntityType } from '@/src/types/common';
+import { EntityType, FeatureType, UploadStatus } from '@/src/types/common';
+import { FolderType } from '@/src/types/folder';
import { AppEpic } from '@/src/types/store';
import { resetShareEntity } from '@/src/constants/chat';
-import { DEFAULT_CONVERSATION_NAME } from '@/src/constants/default-settings';
+import {
+ DEFAULT_CONVERSATION_NAME,
+ DEFAULT_SYSTEM_PROMPT,
+ DEFAULT_TEMPERATURE,
+} from '@/src/constants/default-settings';
import { errorsMessages } from '@/src/constants/errors';
+import { defaultReplay } from '@/src/constants/replay';
+import { RootState } from '..';
import { AddonsActions } from '../addons/addons.reducers';
import { ModelsActions, ModelsSelectors } from '../models/models.reducers';
-import { UIActions } from '../ui/ui.reducers';
+import { UIActions, UISelectors } from '../ui/ui.reducers';
import {
ConversationsActions,
ConversationsSelectors,
} from './conversations.reducers';
-import { v4 as uuidv4 } from 'uuid';
+const initEpic: AppEpic = (action$) =>
+ action$.pipe(
+ filter((action) => ConversationsActions.init.match(action)),
+ switchMap(() =>
+ concat(
+ of(ConversationsActions.initSelectedConversations()),
+ of(ConversationsActions.initFoldersAndConversations()),
+ ),
+ ),
+ );
+
+const initSelectedConversationsEpic: AppEpic = (action$) =>
+ action$.pipe(
+ filter(ConversationsActions.initSelectedConversations.match),
+ switchMap(() => ConversationService.getSelectedConversationsIds()),
+ switchMap((selectedIds) => {
+ if (!selectedIds.length) {
+ return forkJoin({
+ selectedConversations: of([]),
+ selectedIds: of([]),
+ });
+ }
+ return forkJoin({
+ selectedConversations: zip(
+ selectedIds.map((id) =>
+ ConversationService.getConversation(parseConversationId(id)),
+ ),
+ ),
+ selectedIds: of(selectedIds),
+ });
+ }),
+ map(({ selectedConversations, selectedIds }) => {
+ const conversations = selectedConversations
+ .filter(Boolean)
+ .map((conv) => addGeneratedConversationId(conv!)) as Conversation[];
+ if (!selectedIds.length || !conversations.length) {
+ return {
+ conversations: [],
+ selectedConversationsIds: [],
+ };
+ }
+
+ const existingSelectedConversationsIds = selectedIds.filter((id) =>
+ conversations.some((conv) => conv.id === id),
+ );
+
+ return {
+ conversations,
+ selectedConversationsIds: existingSelectedConversationsIds,
+ };
+ }),
+ switchMap(({ conversations, selectedConversationsIds }) => {
+ const actions: Observable[] = [];
+ if (conversations.length) {
+ actions.push(
+ of(
+ ConversationsActions.addConversations({
+ conversations,
+ selectAdded: true,
+ }),
+ ),
+ );
+ }
+ actions.push(
+ of(
+ ConversationsActions.selectConversations({
+ conversationIds: selectedConversationsIds,
+ }),
+ ),
+ );
+ if (!conversations.length || !selectedConversationsIds.length) {
+ actions.push(
+ of(
+ ConversationsActions.createNewConversations({
+ names: [translate(DEFAULT_CONVERSATION_NAME)],
+ }),
+ ),
+ );
+ }
+ actions.push(
+ of(ConversationsActions.uploadConversationsWithFoldersRecursive()),
+ );
+
+ return concat(...actions);
+ }),
+ );
+
+const initFoldersAndConversationsEpic: AppEpic = (action$) =>
+ action$.pipe(
+ filter((action) =>
+ ConversationsActions.initFoldersAndConversations.match(action),
+ ),
+ switchMap(() => ConversationService.getSelectedConversationsIds()),
+ switchMap((selectedIds) => {
+ const paths = selectedIds.flatMap((id) => getAllPathsFromId(id));
+ const uploadPaths = [undefined, ...paths];
+ return zip(
+ uploadPaths.map((path) =>
+ ConversationService.getConversationsAndFolders(path),
+ ),
+ ).pipe(
+ switchMap((foldersAndEntities) => {
+ const folders = foldersAndEntities.flatMap((f) => f.folders);
+ const conversations = foldersAndEntities.flatMap((f) => f.entities);
+ return concat(
+ of(
+ ConversationsActions.setFolders({
+ folders,
+ }),
+ ),
+ of(
+ ConversationsActions.setConversations({
+ conversations,
+ }),
+ ),
+ of(
+ UIActions.setOpenedFoldersIds({
+ openedFolderIds: paths,
+ featureType: FeatureType.Chat,
+ }),
+ ),
+ );
+ }),
+ );
+ }),
+ );
-const createNewConversationEpic: AppEpic = (action$, state$) =>
+const createNewConversationsEpic: AppEpic = (action$, state$) =>
action$.pipe(
filter(ConversationsActions.createNewConversations.match),
map(({ payload }) => ({
@@ -82,39 +229,233 @@ const createNewConversationEpic: AppEpic = (action$, state$) =>
lastConversation: ConversationsSelectors.selectLastConversation(
state$.value,
),
+ conversations: ConversationsSelectors.selectConversations(state$.value),
})),
- switchMap(({ names, lastConversation }) => {
+ switchMap(({ names, lastConversation, conversations }) =>
+ forkJoin({
+ names: of(names),
+ lastConversation:
+ lastConversation && lastConversation.status !== UploadStatus.LOADED
+ ? ConversationService.getConversation(lastConversation)
+ : (of(lastConversation) as Observable),
+ conversations: of(conversations),
+ }),
+ ),
+ switchMap(({ names, lastConversation, conversations }) => {
return state$.pipe(
startWith(state$.value),
map((state) => ModelsSelectors.selectRecentModels(state)),
filter((models) => models && models.length > 0),
take(1),
- map((recentModels) => ({
- lastConversation: ConversationsSelectors.selectLastConversation(
- state$.value,
- ),
- recentModels: recentModels,
- })),
- switchMap(({ recentModels }) => {
+ switchMap((recentModels) => {
const model = recentModels[0];
if (!model) {
return EMPTY;
}
- return of(
- ConversationsActions.createNewConversationsSuccess({
- names,
- temperature: lastConversation?.temperature,
- model,
- }),
+ const newConversations: Conversation[] = names.map(
+ (name: string, index): Conversation => {
+ return addGeneratedConversationId({
+ name:
+ name !== DEFAULT_CONVERSATION_NAME
+ ? name
+ : getNextDefaultName(
+ DEFAULT_CONVERSATION_NAME,
+ conversations.filter((conv) => !conv.folderId), //only root conversations
+ index,
+ ),
+ messages: [],
+ model: {
+ id: model.id,
+ },
+ prompt: DEFAULT_SYSTEM_PROMPT,
+ temperature:
+ lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
+ replay: defaultReplay,
+ selectedAddons: [],
+ lastActivityDate: Date.now(),
+ isMessageStreaming: false,
+ status: UploadStatus.LOADED,
+ });
+ },
+ );
+
+ return zip(
+ newConversations.map((info) =>
+ ConversationService.createConversation(info),
+ ),
+ ).pipe(
+ switchMap(() =>
+ of(
+ ConversationsActions.addConversations({
+ conversations: newConversations,
+ selectAdded: true,
+ }),
+ ),
+ ),
);
}),
);
}),
);
-const createNewConversationSuccessEpic: AppEpic = (action$) =>
+const createNewReplayConversationEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.createNewReplayConversation.match),
+ switchMap(({ payload }) =>
+ forkJoin({
+ conversationAndPayload: getOrUploadConversation(payload, state$.value),
+ conversations: of(
+ ConversationsSelectors.selectConversations(state$.value),
+ ),
+ }),
+ ),
+ switchMap(({ conversationAndPayload, conversations }) => {
+ const { conversation } = conversationAndPayload;
+ if (!conversation) return EMPTY; // TODO: handle?
+
+ const folderId = ConversationsSelectors.hasExternalParent(
+ state$.value,
+ conversation.folderId,
+ )
+ ? undefined
+ : conversation.folderId;
+
+ const newConversationName = getNextDefaultName(
+ `[Replay] ${conversation.name}`,
+ conversations.filter((conv) => conv.folderId === folderId), //only conversations in the same folder
+ 0,
+ true,
+ );
+
+ const userMessages = conversation.messages.filter(
+ ({ role }) => role === Role.User,
+ );
+ const newConversation: Conversation = addGeneratedConversationId({
+ ...conversation,
+ ...resetShareEntity,
+ folderId,
+ name: newConversationName,
+ messages: [],
+ lastActivityDate: Date.now(),
+
+ replay: {
+ isReplay: true,
+ replayUserMessagesStack: userMessages,
+ activeReplayIndex: 0,
+ replayAsIs: true,
+ },
+ isReplay: true,
+ isPlayback: false,
+ playback: {
+ isPlayback: false,
+ activePlaybackIndex: 0,
+ messagesStack: [],
+ },
+ });
+
+ return of(
+ ConversationsActions.createNewConversationSuccess({
+ newConversation,
+ }),
+ );
+ }),
+ );
+
+const createNewPlaybackConversationEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.createNewPlaybackConversation.match),
+ switchMap(({ payload }) =>
+ forkJoin({
+ conversationAndPayload: getOrUploadConversation(payload, state$.value),
+ conversations: of(
+ ConversationsSelectors.selectConversations(state$.value),
+ ),
+ }),
+ ),
+ switchMap(({ conversationAndPayload, conversations }) => {
+ const { conversation } = conversationAndPayload;
+ if (!conversation) return EMPTY; // TODO: handle?
+
+ const folderId = ConversationsSelectors.hasExternalParent(
+ state$.value,
+ conversation.folderId,
+ )
+ ? undefined
+ : conversation.folderId;
+
+ const newConversationName = getNextDefaultName(
+ `[Playback] ${conversation.name}`,
+ conversations.filter((conv) => conv.folderId === folderId), //only conversations in the same folder
+ 0,
+ true,
+ );
+
+ const newConversation: Conversation = addGeneratedConversationId({
+ ...conversation,
+ ...resetShareEntity,
+ folderId,
+ name: newConversationName,
+ messages: [],
+ lastActivityDate: Date.now(),
+
+ playback: {
+ messagesStack: conversation.messages,
+ activePlaybackIndex: 0,
+ isPlayback: true,
+ },
+ isReplay: false,
+ isPlayback: true,
+ replay: {
+ isReplay: false,
+ replayUserMessagesStack: [],
+ activeReplayIndex: 0,
+ replayAsIs: false,
+ },
+ });
+
+ return of(
+ ConversationsActions.createNewConversationSuccess({
+ newConversation,
+ }),
+ );
+ }),
+ );
+
+const duplicateConversationEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.duplicateConversation.match),
+ switchMap(({ payload }) =>
+ forkJoin({
+ conversation: ConversationService.getConversation(payload),
+ }),
+ ),
+ switchMap(({ conversation }) => {
+ if (!conversation) return EMPTY;
+
+ const newConversation: Conversation = addGeneratedConversationId({
+ ...conversation,
+ ...resetShareEntity,
+ folderId: undefined,
+ name: generateNextName(
+ DEFAULT_CONVERSATION_NAME,
+ conversation.name,
+ state$.value.conversations,
+ 0,
+ ),
+ lastActivityDate: Date.now(),
+ });
+
+ return of(
+ ConversationsActions.createNewConversationSuccess({
+ newConversation,
+ }),
+ );
+ }),
+ );
+
+const createNewConversationsSuccessEpic: AppEpic = (action$) =>
action$.pipe(
filter(ConversationsActions.createNewConversations.match),
switchMap(() =>
@@ -122,34 +463,155 @@ const createNewConversationSuccessEpic: AppEpic = (action$) =>
),
);
-const deleteFolderEpic: AppEpic = (action$, state$) =>
+const createNewConversationSuccessEpic: AppEpic = (action$) =>
action$.pipe(
- filter(ConversationsActions.deleteFolder.match),
- map(({ payload }) => ({
- conversations: ConversationsSelectors.selectConversations(state$.value),
- childFolders: ConversationsSelectors.selectChildAndCurrentFoldersIdsById(
- state$.value,
- payload.folderId,
+ filter((action) =>
+ ConversationsActions.createNewConversationSuccess.match(action),
+ ),
+ switchMap(({ payload }) =>
+ ConversationService.createConversation(payload.newConversation).pipe(
+ switchMap(() => EMPTY), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
),
- folders: ConversationsSelectors.selectFolders(state$.value),
- })),
- switchMap(({ conversations, childFolders, folders }) => {
- const removedConversationsIds = conversations
- .filter((conv) => conv.folderId && childFolders.has(conv.folderId))
- .map((conv) => conv.id);
+ ),
+ );
- return concat(
- of(
- ConversationsActions.deleteConversations({
- conversationIds: removedConversationsIds,
- }),
+const deleteFolderEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.deleteFolder.match),
+ switchMap(({ payload }) =>
+ forkJoin({
+ folderId: of(payload.folderId),
+ conversations: ConversationService.getConversations(
+ payload.folderId,
+ true,
),
+ folders: of(ConversationsSelectors.selectFolders(state$.value)),
+ }),
+ ),
+ switchMap(({ folderId, conversations, folders }) => {
+ const childFolders = new Set([
+ folderId,
+ ...conversations.flatMap((conv) => getAllPathsFromPath(conv.folderId)),
+ ]);
+ const removedConversationsIds = conversations.map((conv) => conv.id);
+ const actions: Observable[] = [];
+ actions.push(
of(
ConversationsActions.setFolders({
folders: folders.filter((folder) => !childFolders.has(folder.id)),
}),
),
);
+ if (removedConversationsIds.length) {
+ actions.push(
+ of(
+ ConversationsActions.deleteConversations({
+ conversationIds: removedConversationsIds,
+ }),
+ ),
+ );
+ }
+
+ return concat(...actions);
+ }),
+ );
+
+const updateFolderEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.updateFolder.match),
+ switchMap(({ payload }) => {
+ const folder = getFolderFromPath(payload.folderId, FolderType.Chat);
+ const newFolder = addGeneratedFolderId({ ...folder, ...payload.values });
+
+ if (payload.folderId === newFolder.id) {
+ return EMPTY;
+ }
+
+ return ConversationService.getConversations(payload.folderId, true).pipe(
+ switchMap((conversations) => {
+ const updateFolderId = updateMovedFolderId.bind(
+ null,
+ payload.folderId,
+ newFolder.id,
+ );
+ const updateEntityId = updateMovedEntityId.bind(
+ null,
+ payload.folderId,
+ newFolder.id,
+ );
+
+ const folders = ConversationsSelectors.selectFolders(state$.value);
+ const allConversations = ConversationsSelectors.selectConversations(
+ state$.value,
+ );
+ const openedFoldersIds = UISelectors.selectOpenedFoldersIds(
+ state$.value,
+ FeatureType.Chat,
+ );
+ const selectedConversationsIds =
+ ConversationsSelectors.selectSelectedConversationsIds(state$.value);
+
+ const { updatedFolders, updatedOpenedFoldersIds } =
+ updateEntitiesFoldersAndIds(
+ conversations,
+ folders,
+ updateFolderId,
+ openedFoldersIds,
+ );
+
+ const updatedConversations = combineEntities(
+ allConversations.map((conv) =>
+ addGeneratedConversationId({
+ ...conv,
+ folderId: updateFolderId(conv.folderId),
+ }),
+ ),
+ conversations.map((conv) =>
+ addGeneratedConversationId({
+ ...conv,
+ folderId: updateFolderId(conv.folderId),
+ }),
+ ),
+ );
+
+ const updatedSelectedConversationsIds = selectedConversationsIds.map(
+ (id) => updateEntityId(id),
+ );
+
+ const actions: Observable[] = [];
+ actions.push(
+ of(
+ ConversationsActions.updateFolderSuccess({
+ folders: updatedFolders,
+ conversations: updatedConversations,
+ selectedConversationsIds: updatedSelectedConversationsIds,
+ }),
+ ),
+ of(
+ UIActions.setOpenedFoldersIds({
+ openedFolderIds: updatedOpenedFoldersIds,
+ featureType: FeatureType.Chat,
+ }),
+ ),
+ );
+ if (conversations.length) {
+ conversations.forEach((conversation) => {
+ actions.push(
+ of(
+ ConversationsActions.updateConversation({
+ id: conversation.id,
+ values: {
+ folderId: updateFolderId(conversation.folderId),
+ },
+ }),
+ ),
+ );
+ });
+ }
+
+ return concat(...actions);
+ }),
+ );
}),
);
@@ -157,10 +619,9 @@ const clearConversationsEpic: AppEpic = (action$) =>
action$.pipe(
filter(ConversationsActions.clearConversations.match),
switchMap(() => {
- return of(
- ConversationsActions.createNewConversations({
- names: [translate(DEFAULT_CONVERSATION_NAME)],
- }),
+ return concat(
+ of(ConversationsActions.clearConversationsSuccess()),
+ of(ConversationsActions.deleteFolder({})),
);
}),
);
@@ -168,79 +629,67 @@ const clearConversationsEpic: AppEpic = (action$) =>
const deleteConversationsEpic: AppEpic = (action$, state$) =>
action$.pipe(
filter(ConversationsActions.deleteConversations.match),
- map(() => ({
+ map(({ payload }) => ({
conversations: ConversationsSelectors.selectConversations(state$.value),
selectedConversationsIds:
ConversationsSelectors.selectSelectedConversationsIds(state$.value),
+ deleteIds: new Set(payload.conversationIds),
})),
- switchMap(({ conversations, selectedConversationsIds }) => {
- if (conversations.length === 0) {
- return of(
- ConversationsActions.createNewConversations({
- names: [translate(DEFAULT_CONVERSATION_NAME)],
- }),
- );
- } else if (selectedConversationsIds.length === 0) {
- return of(
- ConversationsActions.selectConversations({
- conversationIds: [conversations[conversations.length - 1].id],
- }),
- );
- }
-
- return EMPTY;
- }),
- );
-
-const initConversationsEpic: AppEpic = (action$) =>
- action$.pipe(
- filter(ConversationsActions.initConversations.match),
- switchMap(() =>
- forkJoin({
- conversations: DataService.getConversations(),
- selectedConversationsIds: DataService.getSelectedConversationsIds(),
- }),
- ),
- map(({ conversations, selectedConversationsIds }) => {
- if (!conversations.length) {
- return {
- conversations,
- selectedConversationsIds: [],
- };
- }
-
- const existingSelectedConversationsIds = selectedConversationsIds.filter(
- (id) => conversations.some((conv) => conv.id === id),
+ switchMap(({ conversations, selectedConversationsIds, deleteIds }) => {
+ const otherConversations = conversations.filter(
+ (conv) => !deleteIds.has(conv.id),
);
- return {
- conversations,
- selectedConversationsIds: existingSelectedConversationsIds,
- };
- }),
- switchMap(({ conversations, selectedConversationsIds }) => {
- const actions: Observable[] = [];
- actions.push(
- of(ConversationsActions.updateConversations({ conversations })),
+ const newSelectedConversationsIds = selectedConversationsIds.filter(
+ (id) => !deleteIds.has(id),
);
- actions.push(
+
+ const actions: Observable[] = [
of(
- ConversationsActions.selectConversations({
- conversationIds: selectedConversationsIds,
+ ConversationsActions.deleteConversationsSuccess({
+ deleteIds,
}),
),
- );
- if (!conversations.length || !selectedConversationsIds.length) {
+ ];
+
+ if (otherConversations.length === 0) {
+ actions.push(
+ of(
+ ConversationsActions.createNewConversations({
+ names: [translate(DEFAULT_CONVERSATION_NAME)],
+ }),
+ ),
+ );
+ } else if (newSelectedConversationsIds.length === 0) {
+ actions.push(
+ of(
+ ConversationsActions.selectConversations({
+ conversationIds: [
+ otherConversations.sort(compareConversationsByDate)[0].id,
+ ],
+ }),
+ ),
+ );
+ } else if (
+ newSelectedConversationsIds.length !== selectedConversationsIds.length
+ ) {
actions.push(
of(
- ConversationsActions.createNewConversations({
- names: [translate(DEFAULT_CONVERSATION_NAME)],
+ ConversationsActions.selectConversations({
+ conversationIds: newSelectedConversationsIds,
}),
),
);
}
- return concat(...actions);
+ return concat(
+ ...actions,
+ zip(
+ Array.from(deleteIds).map((id) =>
+ ConversationService.deleteConversation(parseConversationId(id)),
+ ),
+ ).pipe(switchMap(() => EMPTY)), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
}),
);
@@ -264,7 +713,9 @@ const rateMessageEpic: AppEpic = (action$, state$) =>
}),
);
}
- const message = conversation.messages[payload.messageIndex];
+ const message = (conversation as Conversation).messages[
+ payload.messageIndex
+ ];
if (!message || !message.responseId) {
return of(
@@ -318,7 +769,7 @@ const updateMessageEpic: AppEpic = (action$, state$) =>
switchMap(({ conversations, payload }) => {
const conversation = conversations.find(
(conv) => conv.id === payload.conversationId,
- );
+ ) as Conversation;
if (!conversation || !conversation.messages[payload.messageIndex]) {
return EMPTY;
}
@@ -380,8 +831,11 @@ const sendMessageEpic: AppEpic = (action$, state$) =>
map(({ payload }) => ({
payload,
modelsMap: ModelsSelectors.selectModelsMap(state$.value),
+ conversations: ConversationsSelectors.selectConversations(state$.value),
+ selectedConversationIds:
+ ConversationsSelectors.selectSelectedConversationsIds(state$.value),
})),
- map(({ payload, modelsMap }) => {
+ map(({ payload, modelsMap, conversations, selectedConversationIds }) => {
const messageModel: Message[EntityType.Model] = {
id: payload.conversation.model.id,
};
@@ -414,13 +868,22 @@ const sendMessageEpic: AppEpic = (action$, state$) =>
: payload.conversation.messages
).concat(userMessage, assistantMessage);
- const newConversationName = getNewConversationName(
- payload.conversation,
- payload.message,
- updatedMessages,
+ const newConversationName = getNextDefaultName(
+ getNewConversationName(
+ payload.conversation,
+ payload.message,
+ updatedMessages,
+ ),
+ conversations.filter(
+ (conv) =>
+ conv.folderId === payload.conversation.folderId &&
+ conv.id !== payload.conversation.id,
+ ),
+ Math.max(selectedConversationIds.indexOf(payload.conversation.id), 0),
+ true,
);
- const updatedConversation: Conversation = {
+ const updatedConversation: Conversation = addGeneratedConversationId({
...payload.conversation,
lastActivityDate: Date.now(),
replay: {
@@ -430,41 +893,46 @@ const sendMessageEpic: AppEpic = (action$, state$) =>
messages: updatedMessages,
name: newConversationName,
isMessageStreaming: true,
- };
+ });
return {
+ oldConversationId: payload.conversation.id,
updatedConversation,
- payload,
modelsMap,
assistantMessage,
};
}),
switchMap(
- ({ payload, modelsMap, updatedConversation, assistantMessage }) => {
+ ({
+ oldConversationId,
+ modelsMap,
+ updatedConversation,
+ assistantMessage,
+ }) => {
return concat(
+ of(
+ ConversationsActions.updateConversation({
+ id: oldConversationId,
+ values: updatedConversation,
+ }),
+ ),
of(
ModelsActions.updateRecentModels({
- modelId: payload.conversation.model.id,
+ modelId: updatedConversation.model.id,
}),
),
iif(
() =>
- payload.conversation.selectedAddons.length > 0 &&
- modelsMap[payload.conversation.model.id]?.type !==
+ updatedConversation.selectedAddons.length > 0 &&
+ modelsMap[updatedConversation.model.id]?.type !==
EntityType.Application,
of(
AddonsActions.updateRecentAddons({
- addonIds: payload.conversation.selectedAddons,
+ addonIds: updatedConversation.selectedAddons,
}),
),
EMPTY,
),
- of(
- ConversationsActions.updateConversation({
- id: payload.conversation.id,
- values: updatedConversation,
- }),
- ),
of(
ConversationsActions.streamMessage({
conversation: updatedConversation,
@@ -743,7 +1211,6 @@ const streamMessageFailEpic: AppEpic = (action$, state$) =>
type: 'error',
}),
),
- of(ConversationsActions.cleanMessage()),
);
}),
);
@@ -845,6 +1312,7 @@ const replayConversationsEpic: AppEpic = (action$) =>
ConversationsActions.replayConversation({
...payload,
conversationId: id,
+ activeReplayIndex: 0,
}),
);
}),
@@ -860,7 +1328,7 @@ const replayConversationEpic: AppEpic = (action$, state$) =>
conversation: ConversationsSelectors.selectConversation(
state$.value,
payload.conversationId,
- ),
+ ) as Conversation,
})),
filter(({ conversation }) => !!conversation),
switchMap(({ payload, conversation }) => {
@@ -905,7 +1373,7 @@ const replayConversationEpic: AppEpic = (action$, state$) =>
? clearStateForMessages(conv.messages)
: conv.messages;
- updatedConversation = {
+ updatedConversation = addGeneratedConversationId({
...conv,
model: model,
messages,
@@ -914,7 +1382,7 @@ const replayConversationEpic: AppEpic = (action$, state$) =>
isError: false,
},
...newConversationSettings,
- };
+ });
}
return concat(
@@ -944,28 +1412,18 @@ const replayConversationEpic: AppEpic = (action$, state$) =>
);
}),
switchMap(() => {
- const convReplay = ConversationsSelectors.selectConversation(
- state$.value,
- conv.id,
- )!.replay;
+ const convReplay = (
+ ConversationsSelectors.selectConversation(
+ state$.value,
+ conv.id,
+ ) as Conversation
+ ).replay;
- return concat(
- of(
- ConversationsActions.updateConversation({
- id: payload.conversationId,
- values: {
- replay: {
- ...convReplay,
- activeReplayIndex: conv.replay.activeReplayIndex + 1,
- },
- },
- }),
- ),
- of(
- ConversationsActions.replayConversation({
- conversationId: payload.conversationId,
- }),
- ),
+ return of(
+ ConversationsActions.replayConversation({
+ conversationId: updatedConversation.id,
+ activeReplayIndex: convReplay.activeReplayIndex + 1,
+ }),
);
}),
),
@@ -997,6 +1455,7 @@ const endReplayConversationEpic: AppEpic = (action$, state$) =>
ConversationsActions.updateConversation({
id: conv.id,
values: {
+ isReplay: false,
replay: {
...conv.replay,
isReplay: false,
@@ -1016,8 +1475,7 @@ const saveFoldersEpic: AppEpic = (action$, state$) =>
(action) =>
ConversationsActions.createFolder.match(action) ||
ConversationsActions.deleteFolder.match(action) ||
- ConversationsActions.renameFolder.match(action) ||
- ConversationsActions.moveFolder.match(action) ||
+ ConversationsActions.updateFolderSuccess.match(action) ||
ConversationsActions.clearConversations.match(action) ||
ConversationsActions.importConversationsSuccess.match(action) ||
ConversationsActions.addFolders.match(action) ||
@@ -1028,7 +1486,7 @@ const saveFoldersEpic: AppEpic = (action$, state$) =>
conversationsFolders: ConversationsSelectors.selectFolders(state$.value),
})),
switchMap(({ conversationsFolders }) => {
- return DataService.setConversationFolders(conversationsFolders);
+ return ConversationService.setConversationFolders(conversationsFolders);
}),
ignoreElements(),
);
@@ -1037,13 +1495,13 @@ const selectConversationsEpic: AppEpic = (action$, state$) =>
action$.pipe(
filter(
(action) =>
+ ConversationsActions.updateFolderSuccess.match(action) ||
ConversationsActions.selectConversations.match(action) ||
ConversationsActions.unselectConversations.match(action) ||
- ConversationsActions.createNewConversationsSuccess.match(action) ||
- ConversationsActions.createNewReplayConversation.match(action) ||
+ ConversationsActions.updateConversationSuccess.match(action) ||
+ ConversationsActions.createNewConversationSuccess.match(action) ||
ConversationsActions.importConversationsSuccess.match(action) ||
- ConversationsActions.createNewPlaybackConversation.match(action) ||
- ConversationsActions.deleteConversations.match(action) ||
+ ConversationsActions.deleteConversationsSuccess.match(action) ||
ConversationsActions.addConversations.match(action) ||
ConversationsActions.duplicateConversation.match(action) ||
ConversationsActions.duplicateSelectedConversations.match(action),
@@ -1054,39 +1512,80 @@ const selectConversationsEpic: AppEpic = (action$, state$) =>
switchMap((selectedConversationsIds) =>
forkJoin({
selectedConversationsIds: of(selectedConversationsIds),
- _: DataService.setSelectedConversationsIds(selectedConversationsIds),
+ _: ConversationService.setSelectedConversationsIds(
+ selectedConversationsIds,
+ ),
}),
),
switchMap(({ selectedConversationsIds }) =>
- iif(
- () => selectedConversationsIds.length > 1,
- of(UIActions.setIsCompareMode(true)),
- of(UIActions.setIsCompareMode(false)),
+ concat(
+ of(UIActions.setIsCompareMode(selectedConversationsIds.length > 1)),
),
),
);
-const saveConversationsEpic: AppEpic = (action$, state$) =>
+const uploadSelectedConversationsEpic: AppEpic = (action$, state$) =>
action$.pipe(
- filter(
- (action) =>
- ConversationsActions.createNewConversationsSuccess.match(action) ||
- ConversationsActions.createNewReplayConversation.match(action) ||
- ConversationsActions.updateConversation.match(action) ||
- ConversationsActions.updateConversations.match(action) ||
- ConversationsActions.importConversationsSuccess.match(action) ||
- ConversationsActions.deleteConversations.match(action) ||
- ConversationsActions.createNewPlaybackConversation.match(action) ||
- ConversationsActions.addConversations.match(action) ||
- ConversationsActions.unpublishConversation.match(action) ||
- ConversationsActions.duplicateConversation.match(action) ||
- ConversationsActions.duplicateSelectedConversations.match(action),
+ filter(ConversationsActions.selectConversations.match),
+ map(() =>
+ ConversationsSelectors.selectSelectedConversationsIds(state$.value),
+ ),
+ switchMap((selectedConversationsIds) =>
+ concat(
+ of(
+ ConversationsActions.uploadConversationsByIds({
+ conversationIds: selectedConversationsIds,
+ showLoader: true,
+ }),
+ ),
+ ),
),
- map(() => ConversationsSelectors.selectConversations(state$.value)),
- switchMap((conversations) => {
- return DataService.setConversations(conversations);
+ );
+
+const compareConversationsEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.selectForCompare.match),
+ switchMap(({ payload }) => getOrUploadConversation(payload, state$.value)),
+ switchMap(({ conversation: chosenConversation }) => {
+ const selectedConversation =
+ ConversationsSelectors.selectSelectedConversations(state$.value)[0];
+ const isInvalid =
+ !chosenConversation ||
+ !isChosenConversationValidForCompare(
+ selectedConversation,
+ chosenConversation as Conversation,
+ );
+ const actions: Observable[] = [];
+ if (isInvalid) {
+ actions.push(
+ of(
+ UIActions.showToast({
+ message: translate(
+ 'Incorrect conversation was chosen for comparison. Please choose another one.\r\nOnly conversations containing the same number of messages can be compared.',
+ ),
+ type: 'error',
+ }),
+ ),
+ );
+ } else {
+ actions.push(
+ of(
+ ConversationsActions.selectConversations({
+ conversationIds: [selectedConversation.id, chosenConversation.id],
+ }),
+ ),
+ );
+ }
+ actions.push(
+ of(
+ ConversationsActions.selectForCompareCompleted(
+ chosenConversation as Conversation,
+ ),
+ ),
+ );
+
+ return concat(...actions);
}),
- ignoreElements(),
);
const playbackNextMessageStartEpic: AppEpic = (action$, state$) =>
@@ -1164,7 +1663,7 @@ const playbackNextMessageEndEpic: AppEpic = (action$, state$) =>
selectedConversation: ConversationsSelectors.selectConversation(
state$.value,
payload.conversationId,
- ),
+ ) as Conversation,
})),
switchMap(({ selectedConversation }) => {
if (!selectedConversation) {
@@ -1287,6 +1786,7 @@ const playbackCancelEpic: AppEpic = (action$, state$) =>
values: {
messages: updatedMessages,
isMessageStreaming: false,
+ isPlayback: false,
playback: {
...(conv.playback as Playback),
messagesStack: [],
@@ -1301,297 +1801,290 @@ const playbackCancelEpic: AppEpic = (action$, state$) =>
}),
);
-const initFoldersEpic: AppEpic = (action$) =>
+const uploadConversationsByIdsEpic: AppEpic = (action$, state$) =>
action$.pipe(
- filter((action) => ConversationsActions.initFolders.match(action)),
- switchMap(() =>
- DataService.getConversationsFolders().pipe(
- map((folders) => {
- return ConversationsActions.setFolders({
- folders,
- });
- }),
- ),
- ),
+ filter(ConversationsActions.uploadConversationsByIds.match),
+ switchMap(({ payload }) => {
+ return forkJoin({
+ uploadedConversations: zip(
+ payload.conversationIds.map((id) =>
+ ConversationService.getConversation(
+ ConversationsSelectors.selectConversation(state$.value, id)!,
+ ),
+ ),
+ ),
+ setIds: of(new Set(payload.conversationIds as string[])),
+ showLoader: of(payload.showLoader),
+ });
+ }),
+ switchMap(({ uploadedConversations, setIds, showLoader }) => {
+ const actions: Observable[] = [];
+ actions.push(
+ of(
+ ConversationsActions.uploadConversationsByIdsSuccess({
+ setIds,
+ conversations: uploadedConversations.filter(
+ Boolean,
+ ) as Conversation[],
+ showLoader,
+ }),
+ ),
+ );
+ const conversationsWithIncorrectKeys = uploadedConversations.filter(
+ (conv) => conv && conv.id !== getGeneratedConversationId(conv),
+ ) as Conversation[];
+ if (conversationsWithIncorrectKeys.length) {
+ conversationsWithIncorrectKeys.forEach((conv) =>
+ actions.push(
+ of(
+ ConversationsActions.updateConversation({
+ id: conv.id,
+ values: {
+ ...conv,
+ status: UploadStatus.LOADED,
+ },
+ }),
+ ),
+ ),
+ );
+ }
+ return concat(...actions);
+ }),
);
-const initEpic: AppEpic = (action$) =>
+const saveConversationEpic: AppEpic = (action$) =>
action$.pipe(
- filter((action) => ConversationsActions.init.match(action)),
- switchMap(() =>
- concat(
- of(ConversationsActions.initFolders()),
- of(ConversationsActions.initConversations()),
- ),
+ filter(
+ (action) =>
+ ConversationsActions.saveConversation.match(action) &&
+ !action.payload.isMessageStreaming, // shouldn't save during streaming
),
+ switchMap(({ payload: newConversation }) => {
+ return ConversationService.updateConversation(newConversation).pipe(
+ switchMap(() => EMPTY), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }),
);
-//TODO: added for development purpose - emulate immediate sharing with yourself
-const shareFolderEpic: AppEpic = (action$, state$) =>
+const recreateConversationEpic: AppEpic = (action$) =>
action$.pipe(
- filter(ConversationsActions.shareFolder.match),
- map(({ payload }) => ({
- sharedFolderId: payload.id,
- shareUniqueId: payload.shareUniqueId,
- conversations: ConversationsSelectors.selectConversations(state$.value),
- childFolders: ConversationsSelectors.selectChildAndCurrentFoldersIdsById(
- state$.value,
- payload.id,
- ),
- folders: ConversationsSelectors.selectFolders(state$.value),
- })),
- switchMap(
- ({
- sharedFolderId,
- shareUniqueId,
- conversations,
- childFolders,
- folders,
- }) => {
- const mapping = new Map();
- childFolders.forEach((folderId) => mapping.set(folderId, uuidv4()));
- const newFolders = folders
- .filter(({ id }) => childFolders.has(id))
- .map(({ folderId, ...folder }) => ({
- ...folder,
- id: mapping.get(folder.id),
- originalId: folder.id,
- folderId:
- folder.id === sharedFolderId ? undefined : mapping.get(folderId), // show shared folder on root level
- ...resetShareEntity,
- sharedWithMe: folder.id === sharedFolderId || folder.sharedWithMe,
- shareUniqueId:
- folder.id === sharedFolderId ? shareUniqueId : undefined,
- }));
-
- const sharedConversations = conversations
- .filter(
- (conversation) =>
- conversation.folderId && childFolders.has(conversation.folderId),
- )
- .map(({ folderId, ...conversation }) => ({
- ...conversation,
- ...resetShareEntity,
- id: uuidv4(),
- originalId: conversation.id,
- folderId: mapping.get(folderId),
- }));
-
- return concat(
- of(
- ConversationsActions.addConversations({
- conversations: sharedConversations,
- }),
- ),
- of(
- ConversationsActions.addFolders({
- folders: newFolders,
- }),
- ),
- );
- },
- ),
+ filter(ConversationsActions.recreateConversation.match),
+ mergeMap(({ payload }) => {
+ return concat(
+ ConversationService.createConversation(payload.new).pipe(
+ switchMap(() => EMPTY), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ ),
+ ConversationService.deleteConversation(
+ parseConversationId(payload.old.id),
+ ).pipe(switchMap(() => EMPTY)), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }),
);
-//TODO: added for development purpose - emulate immediate sharing with yourself
-const shareConversationEpic: AppEpic = (action$, state$) =>
+const getOrUploadConversation = (
+ payload: { id: string },
+ state: RootState,
+): Observable<{
+ conversation: Conversation | null;
+ payload: { id: string };
+}> => {
+ const conversation = ConversationsSelectors.selectConversation(
+ state,
+ payload.id,
+ ) as Conversation;
+
+ if (conversation?.status !== UploadStatus.LOADED) {
+ return forkJoin({
+ conversation: ConversationService.getConversation(conversation),
+ payload: of(payload),
+ });
+ } else {
+ return forkJoin({
+ conversation: of(conversation),
+ payload: of(payload),
+ });
+ }
+};
+
+const updateConversationEpic: AppEpic = (action$, state$) =>
action$.pipe(
- filter(ConversationsActions.shareConversation.match),
- map(({ payload }) => ({
- sharedConversationId: payload.id,
- shareUniqueId: payload.shareUniqueId,
- conversations: ConversationsSelectors.selectConversations(state$.value),
- })),
- switchMap(({ sharedConversationId, shareUniqueId, conversations }) => {
- const sharedConversations = conversations
- .filter((conversation) => conversation.id === sharedConversationId)
- .map(({ folderId: _, ...conversation }) => ({
- ...conversation,
- ...resetShareEntity,
- id: uuidv4(),
- originalId: conversation.id,
- folderId: undefined, // show on root level
- sharedWithMe: true,
- shareUniqueId,
- }));
+ filter(ConversationsActions.updateConversation.match),
+ mergeMap(({ payload }) => {
+ return getOrUploadConversation(payload, state$.value);
+ }),
+ mergeMap(({ payload, conversation }) => {
+ const { id, values } = payload as {
+ id: string;
+ values: Partial;
+ };
+ if (!conversation) {
+ return EMPTY; // TODO: handle?
+ }
+ const newConversation: Conversation = addGeneratedConversationId({
+ ...(conversation as Conversation),
+ ...values,
+ lastActivityDate: Date.now(),
+ });
return concat(
of(
- ConversationsActions.addConversations({
- conversations: sharedConversations,
+ ConversationsActions.updateConversationSuccess({
+ id,
+ conversation: {
+ ...values,
+ id: newConversation.id,
+ },
}),
),
+ iif(
+ () => !!conversation && conversation.id !== newConversation.id,
+ of(
+ ConversationsActions.recreateConversation({
+ new: newConversation,
+ old: conversation,
+ }),
+ ),
+ of(ConversationsActions.saveConversation(newConversation)),
+ ),
);
}),
);
-//TODO: added for development purpose - emulate immediate sharing with yourself
-const publishFolderEpic: AppEpic = (action$, state$) =>
+const uploadConversationsWithFoldersEpic: AppEpic = (action$) =>
action$.pipe(
- filter(ConversationsActions.publishFolder.match),
- map(({ payload }) => ({
- publishRequest: payload,
- conversations: ConversationsSelectors.selectConversations(state$.value),
- childFolders: ConversationsSelectors.selectChildAndCurrentFoldersIdsById(
- state$.value,
- payload.id,
- ),
- folders: ConversationsSelectors.selectFolders(state$.value),
- publishedAndTemporaryFolders:
- ConversationsSelectors.selectTemporaryAndFilteredFolders(state$.value),
- })),
- switchMap(
- ({
- publishRequest,
- conversations,
- childFolders,
- folders,
- publishedAndTemporaryFolders,
- }) => {
- const mapping = new Map();
- childFolders.forEach((folderId) => mapping.set(folderId, uuidv4()));
- const newFolders = folders
- .filter(({ id }) => childFolders.has(id))
- .map(({ folderId, ...folder }) => ({
- ...folder,
- ...resetShareEntity,
- id: mapping.get(folder.id),
- originalId: folder.id,
- folderId:
- folder.id === publishRequest.id
- ? getFolderIdByPath(
- publishRequest.path,
- publishedAndTemporaryFolders,
- )
- : mapping.get(folderId),
- name:
- folder.id === publishRequest.id
- ? publishRequest.name
- : folder.name,
- publishedWithMe: true,
- shareUniqueId:
- folder.id === publishRequest.id
- ? publishRequest.shareUniqueId
- : folder.shareUniqueId,
- publishVersion:
- folder.id === publishRequest.id
- ? publishRequest.version
- : folder.publishVersion,
- }));
-
- const rootFolder = findRootFromItems(newFolders);
- const temporaryFolders = getTemporaryFoldersToPublish(
- publishedAndTemporaryFolders,
- rootFolder?.folderId,
- publishRequest.version,
- );
-
- const sharedConversations = conversations
- .filter(
- (conversation) =>
- conversation.folderId && childFolders.has(conversation.folderId),
- )
- .map(({ folderId, ...conversation }) => ({
- ...renameAttachments(
- conversation,
- folderId,
- folders,
- publishRequest.fileNameMapping,
+ filter(ConversationsActions.uploadConversationsWithFolders.match),
+ switchMap(({ payload }) =>
+ zip(
+ payload.paths.map((path) =>
+ ConversationService.getConversationsAndFolders(path),
+ ),
+ ).pipe(
+ switchMap((foldersAndEntities) => {
+ const folders = foldersAndEntities.flatMap((f) => f.folders);
+ const conversations = foldersAndEntities.flatMap((f) => f.entities);
+ return concat(
+ of(
+ ConversationsActions.uploadFoldersSuccess({
+ paths: new Set(payload.paths),
+ folders: folders,
+ }),
),
- ...resetShareEntity,
- id: uuidv4(),
- originalId: conversation.id,
- folderId: mapping.get(folderId),
- }));
-
- return concat(
- of(
- ConversationsActions.addConversations({
- conversations: sharedConversations,
- }),
- ),
- of(
- ConversationsActions.addFolders({
- folders: [...temporaryFolders, ...newFolders],
- }),
+ of(
+ ConversationsActions.uploadConversationsSuccess({
+ paths: new Set(payload.paths),
+ conversations: conversations,
+ }),
+ ),
+ );
+ }),
+ catchError(() =>
+ concat(
+ of(
+ ConversationsActions.uploadFoldersFail({
+ paths: new Set(payload.paths),
+ }),
+ ),
+ of(ConversationsActions.uploadConversationsFail()),
),
- of(ConversationsActions.deleteAllTemporaryFolders()),
- );
- },
+ ), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ ),
),
);
-//TODO: added for development purpose - emulate immediate sharing with yourself
-const publishConversationEpic: AppEpic = (action$, state$) =>
+const uploadConversationsWithFoldersRecursiveEpic: AppEpic = (action$) =>
action$.pipe(
- filter(ConversationsActions.publishConversation.match),
- map(({ payload }) => ({
- publishRequest: payload,
- conversations: ConversationsSelectors.selectConversations(state$.value),
- publishedAndTemporaryFolders:
- ConversationsSelectors.selectTemporaryAndFilteredFolders(state$.value),
- folders: ConversationsSelectors.selectFolders(state$.value),
- })),
- switchMap(
- ({
- publishRequest,
- conversations,
- publishedAndTemporaryFolders,
- folders,
- }) => {
- const sharedConversations = conversations
- .filter((conversation) => conversation.id === publishRequest.id)
- .map(({ folderId, ...conversation }) => ({
- ...renameAttachments(
- conversation,
- folderId,
- folders,
- publishRequest.fileNameMapping,
+ filter(ConversationsActions.uploadConversationsWithFoldersRecursive.match),
+ switchMap(() =>
+ ConversationService.getConversations(undefined, true).pipe(
+ switchMap((conversations) => {
+ const conv = conversations.flat();
+ const folderIds = Array.from(new Set(conv.map((c) => c.folderId)));
+ const paths = Array.from(
+ new Set(folderIds.flatMap((id) => getAllPathsFromPath(id))),
+ );
+ return concat(
+ of(
+ ConversationsActions.uploadConversationsSuccess({
+ paths: new Set(),
+ conversations: conv,
+ }),
),
- ...resetShareEntity,
- id: uuidv4(),
- originalId: conversation.id,
- folderId: getFolderIdByPath(
- publishRequest.path,
- publishedAndTemporaryFolders,
+ of(
+ ConversationsActions.uploadFoldersSuccess({
+ paths: new Set(),
+ folders: getFoldersFromPaths(paths, FolderType.Chat),
+ allLoaded: true,
+ }),
),
- publishedWithMe: true,
- name: publishRequest.name,
- shareUniqueId: publishRequest.shareUniqueId,
- publishVersion: publishRequest.version,
- }));
-
- const rootItem = findRootFromItems(sharedConversations);
- const temporaryFolders = getTemporaryFoldersToPublish(
- publishedAndTemporaryFolders,
- rootItem?.folderId,
- publishRequest.version,
- );
+ );
+ }),
+ catchError(() => of(ConversationsActions.uploadConversationsFail())), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ ),
+ ),
+ );
- return concat(
- of(ConversationsActions.addFolders({ folders: temporaryFolders })),
- of(ConversationsActions.deleteAllTemporaryFolders()),
- of(
- ConversationsActions.addConversations({
- conversations: sharedConversations,
- }),
- ),
- );
- },
+const toggleFolderEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(ConversationsActions.toggleFolder.match),
+ switchMap(({ payload }) => {
+ const openedFoldersIds = UISelectors.selectOpenedFoldersIds(
+ state$.value,
+ FeatureType.Chat,
+ );
+ const isOpened = openedFoldersIds.includes(payload.id);
+ const action = isOpened ? UIActions.closeFolder : UIActions.openFolder;
+ return of(
+ action({
+ id: payload.id,
+ featureType: FeatureType.Chat,
+ }),
+ );
+ }),
+ );
+
+const openFolderEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(
+ (action) =>
+ UIActions.openFolder.match(action) &&
+ action.payload.featureType === FeatureType.Chat,
),
+ switchMap(({ payload }) => {
+ const folder = ConversationsSelectors.selectFolders(state$.value).find(
+ (f) => f.id === payload.id,
+ );
+ if (folder?.status === UploadStatus.LOADED) {
+ return EMPTY;
+ }
+ return concat(
+ of(
+ ConversationsActions.uploadConversationsWithFolders({
+ paths: [payload.id],
+ }),
+ ),
+ );
+ }),
);
export const ConversationsEpics = combineEpics(
+ // init
initEpic,
- initConversationsEpic,
- initFoldersEpic,
-
+ initSelectedConversationsEpic,
+ initFoldersAndConversationsEpic,
+ // update
+ updateConversationEpic,
+ saveConversationEpic,
+ recreateConversationEpic,
+ createNewConversationsEpic,
+ // select
selectConversationsEpic,
- createNewConversationEpic,
+ uploadSelectedConversationsEpic,
+
createNewConversationSuccessEpic,
- saveConversationsEpic,
+ createNewConversationsSuccessEpic,
saveFoldersEpic,
deleteFolderEpic,
+ updateFolderEpic,
clearConversationsEpic,
deleteConversationsEpic,
updateMessageEpic,
@@ -1612,8 +2105,14 @@ export const ConversationsEpics = combineEpics(
playbackPrevMessageEpic,
playbackCancelEpic,
- shareFolderEpic,
- shareConversationEpic,
- publishFolderEpic,
- publishConversationEpic,
+ createNewReplayConversationEpic,
+ createNewPlaybackConversationEpic,
+ duplicateConversationEpic,
+ uploadConversationsByIdsEpic,
+
+ uploadConversationsWithFoldersEpic,
+ uploadConversationsWithFoldersRecursiveEpic,
+ toggleFolderEpic,
+ openFolderEpic,
+ compareConversationsEpic,
);
diff --git a/apps/chat/src/store/conversations/conversations.reducers.ts b/apps/chat/src/store/conversations/conversations.reducers.ts
index 34f6cf5c51..deb8a8b7ad 100644
--- a/apps/chat/src/store/conversations/conversations.reducers.ts
+++ b/apps/chat/src/store/conversations/conversations.reducers.ts
@@ -1,16 +1,17 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
-import { generateNextName, getNextDefaultName } from '@/src/utils/app/folders';
+import { combineEntities } from '@/src/utils/app/common';
+import { addGeneratedConversationId } from '@/src/utils/app/conversation';
+import {
+ addGeneratedFolderId,
+ generateNextName,
+ getNextDefaultName,
+} from '@/src/utils/app/folders';
import { isEntityOrParentsExternal } from '@/src/utils/app/share';
import { translate } from '@/src/utils/app/translation';
-import {
- Conversation,
- ConversationEntityModel,
- Message,
- Role,
-} from '@/src/types/chat';
-import { FeatureType } from '@/src/types/common';
+import { Conversation, ConversationInfo, Message } from '@/src/types/chat';
+import { FeatureType, UploadStatus } from '@/src/types/common';
import { FolderInterface, FolderType } from '@/src/types/folder';
import { SearchFilters } from '@/src/types/search';
import { PublishRequest } from '@/src/types/share';
@@ -18,17 +19,15 @@ import { PublishRequest } from '@/src/types/share';
import { resetShareEntity } from '@/src/constants/chat';
import {
DEFAULT_CONVERSATION_NAME,
- DEFAULT_SYSTEM_PROMPT,
- DEFAULT_TEMPERATURE,
+ DEFAULT_FOLDER_NAME,
} from '@/src/constants/default-settings';
-import { defaultReplay } from '@/src/constants/replay';
-import { hasExternalParent } from './conversations.selectors';
+import * as ConversationsSelectors from './conversations.selectors';
import { ConversationsState } from './conversations.types';
import { v4 as uuidv4 } from 'uuid';
-export * as ConversationsSelectors from './conversations.selectors';
+export { ConversationsSelectors };
const initialState: ConversationsState = {
conversations: [],
@@ -41,6 +40,11 @@ const initialState: ConversationsState = {
isReplayPaused: true,
isPlaybackPaused: true,
newAddedFolderId: undefined,
+ conversationsLoaded: false,
+ areSelectedConversationsLoaded: false,
+ conversationsStatus: UploadStatus.UNINITIALIZED,
+ foldersStatus: UploadStatus.UNINITIALIZED,
+ loadingFolderIds: [],
};
export const conversationsSlice = createSlice({
@@ -48,7 +52,50 @@ export const conversationsSlice = createSlice({
initialState,
reducers: {
init: (state) => state,
- initConversations: (state) => state,
+ initSelectedConversations: (state) => state,
+ initFoldersAndConversations: (state) => state,
+ saveConversation: (state, _action: PayloadAction) => state,
+ recreateConversation: (
+ state,
+ _action: PayloadAction<{ new: Conversation; old: ConversationInfo }>,
+ ) => state,
+ updateConversation: (
+ state,
+ _action: PayloadAction<{ id: string; values: Partial }>,
+ ) => state,
+ updateConversationSuccess: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{ id: string; conversation: Partial }>,
+ ) => {
+ state.conversations = state.conversations.map((conv) => {
+ if (conv.id === payload.id) {
+ return {
+ ...conv,
+ ...payload.conversation,
+ lastActivityDate: Date.now(),
+ };
+ }
+
+ return conv;
+ });
+ if (payload.id !== payload.conversation.id) {
+ state.selectedConversationsIds = state.selectedConversationsIds.map(
+ (cid) => (cid === payload.id ? payload.conversation.id! : cid),
+ );
+ }
+ },
+ selectForCompare: (state, _action: PayloadAction) => {
+ state.compareLoading = true;
+ },
+ selectForCompareCompleted: (
+ state,
+ { payload }: PayloadAction,
+ ) => {
+ state.compareLoading = false;
+ state.conversations = combineEntities([payload], state.conversations);
+ },
selectConversations: (
state,
{ payload }: PayloadAction<{ conversationIds: string[] }>,
@@ -69,59 +116,7 @@ export const conversationsSlice = createSlice({
state,
_action: PayloadAction<{ names: string[] }>,
) => state,
- createNewConversationsSuccess: (
- state,
- {
- payload,
- }: PayloadAction<{
- names: string[];
- temperature: number | undefined;
- model: ConversationEntityModel;
- }>,
- ) => {
- const newConversations: Conversation[] = payload.names.map(
- (name, index): Conversation => {
- return {
- id: uuidv4(),
- name:
- name !== DEFAULT_CONVERSATION_NAME
- ? name
- : getNextDefaultName(
- DEFAULT_CONVERSATION_NAME,
- state.conversations,
- index,
- ),
- messages: [],
- model: {
- id: payload.model.id,
- },
- prompt: DEFAULT_SYSTEM_PROMPT,
- temperature: payload.temperature ?? DEFAULT_TEMPERATURE,
- replay: defaultReplay,
- selectedAddons: [],
- lastActivityDate: Date.now(),
- isMessageStreaming: false,
- };
- },
- );
- state.conversations = state.conversations.concat(newConversations);
- state.selectedConversationsIds = newConversations.map(({ id }) => id);
- },
- updateConversation: (
- state,
- { payload }: PayloadAction<{ id: string; values: Partial }>,
- ) => {
- state.conversations = state.conversations.map((conv) => {
- if (conv.id === payload.id) {
- return {
- ...conv,
- ...payload.values,
- };
- }
- return conv;
- });
- },
shareConversation: (
state,
{ payload }: PayloadAction<{ id: string; shareUniqueId: string }>,
@@ -218,110 +213,69 @@ export const conversationsSlice = createSlice({
deleteConversations: (
state,
- { payload }: PayloadAction<{ conversationIds: string[] }>,
+ _action: PayloadAction<{ conversationIds: string[] }>,
+ ) => state,
+ deleteConversationsSuccess: (
+ state,
+ { payload }: PayloadAction<{ deleteIds: Set }>,
) => {
state.conversations = state.conversations.filter(
- (conv) => !payload.conversationIds.includes(conv.id),
+ (conv) => !payload.deleteIds.has(conv.id),
);
state.selectedConversationsIds = state.selectedConversationsIds.filter(
- (id) => !payload.conversationIds.includes(id),
+ (id) => !payload.deleteIds.has(id),
);
},
- createNewReplayConversation: (
+ uploadConversationsByIds: (
state,
- { payload }: PayloadAction<{ conversation: Conversation }>,
+ {
+ payload,
+ }: PayloadAction<{ conversationIds: string[]; showLoader?: boolean }>,
) => {
- const newConversationName = `[Replay] ${payload.conversation.name}`;
-
- const userMessages = payload.conversation.messages.filter(
- ({ role }) => role === Role.User,
- );
- const newConversation: Conversation = {
- ...payload.conversation,
- ...resetShareEntity,
- folderId: hasExternalParent(
- { conversations: state },
- payload.conversation.folderId,
- )
- ? undefined
- : payload.conversation.folderId,
- id: uuidv4(),
- name: newConversationName,
- messages: [],
- lastActivityDate: Date.now(),
-
- replay: {
- isReplay: true,
- replayUserMessagesStack: userMessages,
- activeReplayIndex: 0,
- replayAsIs: true,
- },
-
- playback: {
- isPlayback: false,
- activePlaybackIndex: 0,
- messagesStack: [],
- },
- };
- state.conversations = state.conversations.concat(newConversation);
- state.selectedConversationsIds = [newConversation.id];
+ if (payload.showLoader) {
+ state.areSelectedConversationsLoaded = false;
+ }
},
- createNewPlaybackConversation: (
+ uploadConversationsByIdsSuccess: (
state,
- { payload }: PayloadAction<{ conversation: Conversation }>,
+ {
+ payload,
+ }: PayloadAction<{
+ setIds: Set;
+ conversations: Conversation[];
+ showLoader?: boolean;
+ }>,
) => {
- const newConversationName = `[Playback] ${payload.conversation.name}`;
-
- const newConversation: Conversation = {
- ...payload.conversation,
- ...resetShareEntity,
- folderId: hasExternalParent(
- { conversations: state },
- payload.conversation.folderId,
- )
- ? undefined
- : payload.conversation.folderId,
- id: uuidv4(),
- name: newConversationName,
- messages: [],
- lastActivityDate: Date.now(),
-
- playback: {
- messagesStack: payload.conversation.messages,
- activePlaybackIndex: 0,
- isPlayback: true,
- },
-
- replay: {
- isReplay: false,
- replayUserMessagesStack: [],
- activeReplayIndex: 0,
- replayAsIs: false,
- },
- };
- state.conversations = state.conversations.concat(newConversation);
- state.selectedConversationsIds = [newConversation.id];
+ state.conversations = combineEntities(
+ payload.conversations.map((conv) => ({
+ ...conv,
+ isMessageStreaming: false, // we shouldn't try to continue stream after upload
+ })),
+ state.conversations.filter((conv) => !payload.setIds.has(conv.id)),
+ );
+ if (payload.showLoader) {
+ state.areSelectedConversationsLoaded = true;
+ }
},
- duplicateConversation: (
+ createNewReplayConversation: (
+ state,
+ _action: PayloadAction,
+ ) => state,
+ createNewConversationSuccess: (
state,
- { payload }: PayloadAction<{ conversation: Conversation }>,
+ {
+ payload: { newConversation },
+ }: PayloadAction<{ newConversation: Conversation }>,
) => {
- const newConversation: Conversation = {
- ...payload.conversation,
- ...resetShareEntity,
- folderId: undefined,
- name: generateNextName(
- DEFAULT_CONVERSATION_NAME,
- payload.conversation.name,
- state.conversations,
- 0,
- ),
- id: uuidv4(),
- lastActivityDate: Date.now(),
- };
state.conversations = state.conversations.concat(newConversation);
state.selectedConversationsIds = [newConversation.id];
},
+ createNewPlaybackConversation: (
+ state,
+ _action: PayloadAction,
+ ) => state,
+ duplicateConversation: (state, _action: PayloadAction) =>
+ state,
duplicateSelectedConversations: (state) => {
const selectedIds = new Set(state.selectedConversationsIds);
const newSelectedIds: string[] = [];
@@ -336,8 +290,8 @@ export const conversationsSlice = createSlice({
FeatureType.Chat,
)
) {
- const newConversation: Conversation = {
- ...conversation,
+ const newConversation: Conversation = addGeneratedConversationId({
+ ...(conversation as Conversation),
...resetShareEntity,
folderId: undefined,
name: generateNextName(
@@ -346,16 +300,15 @@ export const conversationsSlice = createSlice({
state.conversations.concat(newConversations),
0,
),
- id: uuidv4(),
lastActivityDate: Date.now(),
- };
+ });
newConversations.push(newConversation);
newSelectedIds.push(newConversation.id);
} else {
newSelectedIds.push(id);
}
});
- state.conversations = state.conversations.concat(newConversations);
+ state.conversations = state.conversations.concat(newConversations); // TODO: save in API
state.selectedConversationsIds = newSelectedIds;
},
importConversationsSuccess: (
@@ -363,7 +316,7 @@ export const conversationsSlice = createSlice({
{
payload,
}: PayloadAction<{
- conversations: Conversation[];
+ conversations: ConversationInfo[];
folders: FolderInterface[];
}>,
) => {
@@ -373,19 +326,38 @@ export const conversationsSlice = createSlice({
payload.conversations[payload.conversations.length - 1].id,
];
},
- updateConversations: (
+ setConversations: (
state,
- { payload }: PayloadAction<{ conversations: Conversation[] }>,
+ { payload }: PayloadAction<{ conversations: ConversationInfo[] }>,
) => {
- state.conversations = payload.conversations;
+ state.conversations = combineEntities(
+ state.conversations,
+ payload.conversations,
+ );
+ state.conversationsLoaded = true;
},
addConversations: (
state,
- { payload }: PayloadAction<{ conversations: Conversation[] }>,
+ {
+ payload,
+ }: PayloadAction<{
+ conversations: ConversationInfo[];
+ selectAdded?: boolean;
+ }>,
) => {
- state.conversations = state.conversations.concat(payload.conversations);
+ state.conversations = combineEntities(
+ payload.conversations,
+ state.conversations,
+ );
+ if (payload.selectAdded) {
+ state.selectedConversationsIds = payload.conversations.map(
+ ({ id }) => id,
+ );
+ state.areSelectedConversationsLoaded = true;
+ }
},
- clearConversations: (state) => {
+ clearConversations: (state) => state,
+ clearConversationsSuccess: (state) => {
state.conversations = [];
state.folders = [];
},
@@ -393,18 +365,20 @@ export const conversationsSlice = createSlice({
state,
{
payload,
- }: PayloadAction<
- { name?: string; folderId?: string; parentId?: string } | undefined
- >,
+ }: PayloadAction<{ name?: string; parentId?: string } | undefined>,
) => {
- const newFolder: FolderInterface = {
- id: payload?.folderId || uuidv4(),
- folderId: payload?.parentId || undefined,
+ const newFolder: FolderInterface = addGeneratedFolderId({
+ folderId: payload?.parentId,
name:
- payload?.name ?? // custom name
- getNextDefaultName(translate('New folder'), state.folders), // default name with counter
+ // custom name
+ payload?.name ??
+ // default name with counter
+ ConversationsSelectors.selectNewFolderName(
+ { conversations: state },
+ payload?.parentId,
+ ),
type: FolderType.Chat,
- };
+ });
state.folders = state.folders.concat(newFolder);
},
@@ -417,7 +391,7 @@ export const conversationsSlice = createSlice({
}>,
) => {
const folderName = getNextDefaultName(
- translate('New folder'),
+ translate(DEFAULT_FOLDER_NAME),
[
...state.temporaryFolders,
...state.folders.filter((folder) => folder.publishedWithMe),
@@ -437,9 +411,8 @@ export const conversationsSlice = createSlice({
});
state.newAddedFolderId = id;
},
- deleteFolder: (state, { payload }: PayloadAction<{ folderId: string }>) => {
- state.folders = state.folders.filter(({ id }) => id !== payload.folderId);
- },
+ deleteFolder: (state, _action: PayloadAction<{ folderId?: string }>) =>
+ state,
deleteTemporaryFolder: (
state,
{ payload }: PayloadAction<{ folderId: string }>,
@@ -451,34 +424,13 @@ export const conversationsSlice = createSlice({
deleteAllTemporaryFolders: (state) => {
state.temporaryFolders = [];
},
- renameFolder: (
- state,
- { payload }: PayloadAction<{ folderId: string; name: string }>,
- ) => {
- const name = payload.name.trim();
- if (name === '') {
- return;
- }
- state.folders = state.folders.map((folder) => {
- if (folder.id === payload.folderId) {
- return {
- ...folder,
- name,
- };
- }
- return folder;
- });
- },
renameTemporaryFolder: (
state,
{ payload }: PayloadAction<{ folderId: string; name: string }>,
) => {
state.newAddedFolderId = undefined;
const name = payload.name.trim();
- if (name === '') {
- return;
- }
state.temporaryFolders = state.temporaryFolders.map((folder) =>
folder.id !== payload.folderId ? folder : { ...folder, name },
@@ -487,26 +439,37 @@ export const conversationsSlice = createSlice({
resetNewFolderId: (state) => {
state.newAddedFolderId = undefined;
},
- moveFolder: (
+ updateFolder: (
state,
{
payload,
- }: PayloadAction<{
- folderId: string;
- newParentFolderId: string | undefined;
- }>,
+ }: PayloadAction<{ folderId: string; values: Partial }>,
) => {
state.folders = state.folders.map((folder) => {
if (folder.id === payload.folderId) {
return {
...folder,
- folderId: payload.newParentFolderId,
+ ...payload.values,
};
}
return folder;
});
},
+ updateFolderSuccess: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{
+ folders: FolderInterface[];
+ conversations: ConversationInfo[];
+ selectedConversationsIds: string[];
+ }>,
+ ) => {
+ state.folders = payload.folders;
+ state.conversations = payload.conversations;
+ state.selectedConversationsIds = payload.selectedConversationsIds;
+ },
setFolders: (
state,
{ payload }: PayloadAction<{ folders: FolderInterface[] }>,
@@ -517,7 +480,7 @@ export const conversationsSlice = createSlice({
state,
{ payload }: PayloadAction<{ folders: FolderInterface[] }>,
) => {
- state.folders = state.folders.concat(payload.folders);
+ state.folders = combineEntities(payload.folders, state.folders);
},
setSearchTerm: (
state,
@@ -563,7 +526,6 @@ export const conversationsSlice = createSlice({
state,
_action: PayloadAction<{ error: Response | string }>,
) => state,
- cleanMessage: (state) => state,
deleteMessage: (state, _action: PayloadAction<{ index: number }>) => state,
sendMessages: (
state,
@@ -602,13 +564,6 @@ export const conversationsSlice = createSlice({
}>,
) => state,
streamMessageSuccess: (state) => state,
- mergeMessage: (
- state,
- _action: PayloadAction<{
- conversationId: string;
- chunkValue: Partial;
- }>,
- ) => state,
stopStreamMessage: (state) => state,
replayConversations: (
state,
@@ -619,12 +574,27 @@ export const conversationsSlice = createSlice({
) => state,
replayConversation: (
state,
- _action: PayloadAction<{
+ {
+ payload,
+ }: PayloadAction<{
conversationId: string;
isRestart?: boolean;
+ activeReplayIndex: number;
}>,
) => {
state.isReplayPaused = false;
+ state.conversations = (state.conversations as Conversation[]).map(
+ (conv) =>
+ conv.id === payload.conversationId
+ ? {
+ ...conv,
+ replay: {
+ ...conv.replay,
+ activeReplayIndex: payload.activeReplayIndex,
+ },
+ }
+ : conv,
+ );
},
stopReplayConversation: (state) => {
state.isReplayPaused = true;
@@ -652,7 +622,125 @@ export const conversationsSlice = createSlice({
state.isPlaybackPaused = true;
},
- initFolders: (state) => state,
+ uploadConversationsWithFolders: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{
+ paths: (string | undefined)[];
+ }>,
+ ) => {
+ state.foldersStatus = UploadStatus.LOADING;
+ state.loadingFolderIds = state.loadingFolderIds.concat(
+ payload.paths as string[],
+ );
+ },
+
+ // uploadFolders: (
+ // state,
+ // {
+ // payload,
+ // }: PayloadAction<{
+ // paths: (string | undefined)[];
+ // }>,
+ // ) => {
+ // state.foldersStatus = UploadStatus.LOADING;
+ // state.loadingFolderIds = state.loadingFolderIds.concat(
+ // payload.paths as string[],
+ // );
+ // },
+ uploadFoldersSuccess: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{
+ paths: Set;
+ folders: FolderInterface[];
+ allLoaded?: boolean;
+ }>,
+ ) => {
+ state.loadingFolderIds = state.loadingFolderIds.filter(
+ (id) => !payload.paths.has(id),
+ );
+ state.foldersStatus = UploadStatus.LOADED;
+ state.folders = combineEntities(payload.folders, state.folders).map(
+ (f) =>
+ payload.paths.has(f.id)
+ ? {
+ ...f,
+ status: UploadStatus.LOADED,
+ }
+ : f,
+ );
+ state.foldersStatus = payload.allLoaded
+ ? UploadStatus.ALL_LOADED
+ : UploadStatus.LOADED;
+ },
+ uploadFoldersFail: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{
+ paths: Set;
+ }>,
+ ) => {
+ state.loadingFolderIds = state.loadingFolderIds.filter(
+ (id) => !payload.paths.has(id),
+ );
+ state.foldersStatus = UploadStatus.FAILED;
+ },
+
+ // uploadConversations: (
+ // state,
+ // _action: PayloadAction<{
+ // paths: (string | undefined)[];
+ // }>,
+ // ) => {
+ // state.conversationsStatus = UploadStatus.LOADING;
+ // },
+
+ uploadConversationsWithFoldersRecursive: (state) => {
+ state.conversationsStatus = UploadStatus.LOADING;
+ },
+
+ uploadConversationsSuccess: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{
+ paths: Set;
+ conversations: ConversationInfo[];
+ }>,
+ ) => {
+ const conversationMap = state.conversations.reduce((map, conv) => {
+ map.set(conv.id, conv);
+ return map;
+ }, new Map());
+
+ const ids = new Set(payload.conversations.map((c) => c.id));
+
+ state.conversations = combineEntities(
+ payload.conversations.map((conv) =>
+ ids.has(conv.id)
+ ? {
+ ...conversationMap.get(conv.id),
+ ...conv,
+ }
+ : conv,
+ ),
+ state.conversations,
+ );
+ state.conversationsStatus = UploadStatus.LOADED;
+ },
+ uploadConversationsFail: (state) => {
+ state.conversationsStatus = UploadStatus.FAILED;
+ },
+ toggleFolder: (
+ state,
+ _action: PayloadAction<{
+ id: string;
+ }>,
+ ) => state,
},
});
diff --git a/apps/chat/src/store/conversations/conversations.selectors.ts b/apps/chat/src/store/conversations/conversations.selectors.ts
index 0df3f3bcdc..bfd9166d06 100644
--- a/apps/chat/src/store/conversations/conversations.selectors.ts
+++ b/apps/chat/src/store/conversations/conversations.selectors.ts
@@ -1,16 +1,19 @@
import { createSelector } from '@reduxjs/toolkit';
+import { compareConversationsByDate } from '@/src/utils/app/conversation';
import { constructPath } from '@/src/utils/app/file';
import {
+ getAllPathsFromId,
getChildAndCurrentFoldersIdsById,
getConversationAttachmentWithPath,
getFilteredFolders,
+ getNextDefaultName,
getParentAndChildFolders,
getParentAndCurrentFoldersById,
} from '@/src/utils/app/folders';
import {
PublishedWithMeFilter,
- doesConversationContainSearchTerm,
+ doesPromptOrConversationContainSearchTerm,
getMyItemsFilters,
searchSectionFolders,
} from '@/src/utils/app/search';
@@ -18,12 +21,15 @@ import {
isEntityExternal,
isEntityOrParentsExternal,
} from '@/src/utils/app/share';
+import { translate } from '@/src/utils/app/translation';
-import { Conversation, Role } from '@/src/types/chat';
+import { Conversation, ConversationInfo, Role } from '@/src/types/chat';
import { EntityType, FeatureType } from '@/src/types/common';
import { DialFile } from '@/src/types/files';
import { EntityFilters, SearchFilters } from '@/src/types/search';
+import { DEFAULT_FOLDER_NAME } from '@/src/constants/default-settings';
+
import { RootState } from '../index';
import { ModelsSelectors } from '../models/models.reducers';
import { ConversationsState } from './conversations.types';
@@ -45,7 +51,10 @@ export const selectFilteredConversations = createSelector(
return conversations.filter(
(conversation) =>
(!searchTerm ||
- doesConversationContainSearchTerm(conversation, searchTerm)) &&
+ doesPromptOrConversationContainSearchTerm(
+ conversation,
+ searchTerm,
+ )) &&
filters.searchFilter(conversation) &&
(conversation.folderId || filters.sectionFilter(conversation)),
);
@@ -107,13 +116,14 @@ export const selectSectionFolders = createSelector(
export const selectLastConversation = createSelector(
[selectConversations],
- (state): Conversation | undefined => {
- return state[0];
+ (conversations): ConversationInfo | undefined => {
+ if (!conversations.length) return undefined;
+ return [...conversations].sort(compareConversationsByDate)[0];
},
);
export const selectConversation = createSelector(
[selectConversations, (_state, id: string) => id],
- (conversations, id): Conversation | undefined => {
+ (conversations, id): ConversationInfo | undefined => {
return conversations.find((conv) => conv.id === id);
},
);
@@ -143,24 +153,10 @@ export const selectParentFolders = createSelector(
return getParentAndCurrentFoldersById(folders, folderId);
},
);
-const selectParentFoldersIds = createSelector(
- [selectParentFolders],
- (folders) => {
- return folders.map((folder) => folder.id);
- },
-);
export const selectSelectedConversationsFoldersIds = createSelector(
- [(state) => state, selectSelectedConversations],
- (state, conversations) => {
- let selectedFolders: string[] = [];
-
- conversations.forEach((conv) => {
- selectedFolders = selectedFolders.concat(
- selectParentFoldersIds(state, conv.folderId),
- );
- });
-
- return selectedFolders;
+ [selectSelectedConversationsIds],
+ (selectedConversationsIds) => {
+ return selectedConversationsIds.flatMap((id) => getAllPathsFromId(id));
},
);
export const selectChildAndCurrentFoldersIdsById = createSelector(
@@ -204,7 +200,7 @@ export const selectSearchedConversations = createSelector(
[selectConversations, selectSearchTerm],
(conversations, searchTerm) =>
conversations.filter((conversation) =>
- doesConversationContainSearchTerm(conversation, searchTerm),
+ doesPromptOrConversationContainSearchTerm(conversation, searchTerm),
),
);
@@ -220,7 +216,7 @@ export const selectIsSendMessageAborted = createSelector(
export const selectIsReplaySelectedConversations = createSelector(
[selectSelectedConversations],
(conversations) => {
- return conversations.some((conv) => conv.replay.isReplay);
+ return conversations.some((conv) => conv.replay?.isReplay);
},
);
@@ -399,13 +395,16 @@ export const isPublishConversationVersionUnique = createSelector(
(_state: RootState, _entityId: string, version: string) => version,
],
(state, entityId, version) => {
- const conversation = selectConversation(state, entityId);
+ const conversation = selectConversation(state, entityId) as Conversation; // TODO: will be fixed in https://github.com/epam/ai-dial-chat/issues/313
if (!conversation || conversation?.publishVersion === version) return false;
- const conversations = selectConversations(state).filter(
- (conv) => conv.originalId === entityId && conv.publishVersion === version,
- );
+ const conversations = selectConversations(state)
+ .map((conv) => conv as Conversation) // TODO: will be fixed in https://github.com/epam/ai-dial-chat/issues/313
+ .filter(
+ (conv) =>
+ conv.originalId === entityId && conv.publishVersion === version,
+ );
if (conversations.length) return false;
@@ -475,7 +474,10 @@ export const getAttachments = createSelector(
const conversation = selectConversation(state, entityId);
if (conversation) {
return getUniqueAttachments(
- getConversationAttachmentWithPath(conversation, folders),
+ getConversationAttachmentWithPath(
+ conversation as Conversation, //TODO: fix in https://github.com/epam/ai-dial-chat/issues/640
+ folders,
+ ),
);
} else {
const folderIds = new Set(
@@ -490,9 +492,64 @@ export const getAttachments = createSelector(
return getUniqueAttachments(
conversations.flatMap((conv) =>
- getConversationAttachmentWithPath(conv, folders),
+ getConversationAttachmentWithPath(
+ conv as Conversation, //TODO: fix in https://github.com/epam/ai-dial-chat/issues/640
+ folders,
+ ),
),
);
}
},
);
+
+export const areConversationsUploaded = createSelector(
+ [rootSelector],
+ (state) => {
+ return state.conversationsLoaded;
+ },
+);
+
+export const selectFoldersStatus = createSelector([rootSelector], (state) => {
+ return state.foldersStatus;
+});
+
+export const selectConversationsStatus = createSelector(
+ [rootSelector],
+ (state) => {
+ return state.conversationsStatus;
+ },
+);
+
+export const selectAreSelectedConversationsLoaded = createSelector(
+ [rootSelector],
+ (state) => {
+ return state.areSelectedConversationsLoaded;
+ },
+);
+// default name with counter
+export const selectNewFolderName = createSelector(
+ [
+ selectFolders,
+ (_state: RootState, folderId: string | undefined) => folderId,
+ ],
+ (folders, folderId) => {
+ return getNextDefaultName(
+ translate(DEFAULT_FOLDER_NAME),
+ folders.filter((f) => f.folderId === folderId),
+ );
+ },
+);
+
+export const selectLoadingFolderIds = createSelector(
+ [rootSelector],
+ (state) => {
+ return state.loadingFolderIds;
+ },
+);
+
+export const selectIsCompareLoading = createSelector(
+ [rootSelector],
+ (state) => {
+ return state.compareLoading;
+ },
+);
diff --git a/apps/chat/src/store/conversations/conversations.types.ts b/apps/chat/src/store/conversations/conversations.types.ts
index e91b4d585e..ddabe4daa1 100644
--- a/apps/chat/src/store/conversations/conversations.types.ts
+++ b/apps/chat/src/store/conversations/conversations.types.ts
@@ -1,9 +1,10 @@
-import { Conversation } from '@/src/types/chat';
+import { ConversationInfo } from '@/src/types/chat';
+import { UploadStatus } from '@/src/types/common';
import { FolderInterface } from '@/src/types/folder';
import { SearchFilters } from '@/src/types/search';
export interface ConversationsState {
- conversations: Conversation[];
+ conversations: ConversationInfo[];
selectedConversationsIds: string[];
folders: FolderInterface[];
temporaryFolders: FolderInterface[];
@@ -13,4 +14,10 @@ export interface ConversationsState {
isReplayPaused: boolean;
isPlaybackPaused: boolean;
newAddedFolderId?: string;
+ conversationsLoaded: boolean;
+ areSelectedConversationsLoaded: boolean;
+ conversationsStatus: UploadStatus;
+ foldersStatus: UploadStatus;
+ loadingFolderIds: string[];
+ compareLoading?: boolean;
}
diff --git a/apps/chat/src/store/files/files.epics.ts b/apps/chat/src/store/files/files.epics.ts
index c4bfbfcac2..6db92fb1f9 100644
--- a/apps/chat/src/store/files/files.epics.ts
+++ b/apps/chat/src/store/files/files.epics.ts
@@ -14,65 +14,25 @@ import {
import { combineEpics } from 'redux-observable';
-import { DataService } from '@/src/utils/app/data/data-service';
+import { FileService } from '@/src/utils/app/data/file-service';
import { triggerDownload } from '@/src/utils/app/file';
import { translate } from '@/src/utils/app/translation';
+import { UploadStatus } from '@/src/types/common';
import { AppEpic } from '@/src/types/store';
-import { errorsMessages } from '@/src/constants/errors';
-
import { UIActions } from '../ui/ui.reducers';
import { FilesActions, FilesSelectors } from './files.reducers';
-const initEpic: AppEpic = (action$) =>
- action$.pipe(
- filter(FilesActions.init.match),
- switchMap(() => {
- const actions = [];
-
- actions.push(FilesActions.getBucket());
-
- return concat(actions);
- }),
- );
-
-const getBucketEpic: AppEpic = (action$) =>
- action$.pipe(
- filter(FilesActions.getBucket.match),
- switchMap(() => {
- return DataService.getFilesBucket().pipe(
- map(({ bucket }) => {
- return FilesActions.setBucket({ bucket });
- }),
- catchError((error) => {
- if (error.status === 401) {
- window.location.assign('api/auth/signin');
- return EMPTY;
- } else {
- return of(
- UIActions.showToast({
- message: errorsMessages.errorGettingUserFileBucket,
- type: 'error',
- }),
- );
- }
- }),
- );
- }),
- );
-
-const uploadFileEpic: AppEpic = (action$, state$) =>
+const uploadFileEpic: AppEpic = (action$) =>
action$.pipe(
filter(FilesActions.uploadFile.match),
mergeMap(({ payload }) => {
- const bucket = FilesSelectors.selectBucket(state$.value);
const formData = new FormData();
formData.append('attachment', payload.fileContent, payload.name);
- return DataService.sendFile(
+ return FileService.sendFile(
formData,
- bucket,
payload.relativePath,
payload.name,
).pipe(
@@ -127,43 +87,35 @@ const reuploadFileEpic: AppEpic = (action$, state$) =>
}),
);
-const getFilesEpic: AppEpic = (action$, state$) =>
+const getFilesEpic: AppEpic = (action$) =>
action$.pipe(
filter(FilesActions.getFiles.match),
- switchMap(({ payload }) => {
- const bucket = FilesSelectors.selectBucket(state$.value);
-
- return DataService.getFiles(bucket, payload.path).pipe(
- map((files) => {
- return FilesActions.getFilesSuccess({
+ switchMap(({ payload }) =>
+ FileService.getFiles(payload.path).pipe(
+ map((files) =>
+ FilesActions.getFilesSuccess({
relativePath: payload.path,
files,
- });
- }),
- catchError(() => {
- return of(FilesActions.getFilesFail());
- }),
- );
- }),
+ }),
+ ),
+ catchError(() => of(FilesActions.getFilesFail())),
+ ),
+ ),
);
-const getFileFoldersEpic: AppEpic = (action$, state$) =>
+const getFileFoldersEpic: AppEpic = (action$) =>
action$.pipe(
filter(FilesActions.getFolders.match),
- switchMap(({ payload }) => {
- const bucket = FilesSelectors.selectBucket(state$.value);
-
- return DataService.getFileFolders(bucket, payload?.path).pipe(
- map((folders) => {
- return FilesActions.getFoldersSuccess({
+ switchMap(({ payload }) =>
+ FileService.getFileFolders(payload?.path).pipe(
+ map((folders) =>
+ FilesActions.getFoldersSuccess({
folders,
- });
- }),
- catchError(() => {
- return of(FilesActions.getFoldersFail());
- }),
- );
- }),
+ }),
+ ),
+ catchError(() => of(FilesActions.getFoldersFail())),
+ ),
+ ),
);
const getFilesWithFoldersEpic: AppEpic = (action$) =>
@@ -193,8 +145,6 @@ const removeFileEpic: AppEpic = (action$, state$) =>
action$.pipe(
filter(FilesActions.removeFile.match),
mergeMap(({ payload }) => {
- const bucket = FilesSelectors.selectBucket(state$.value);
-
const file = FilesSelectors.selectFiles(state$.value).find(
(file) => file.id === payload.fileId,
);
@@ -214,7 +164,7 @@ const removeFileEpic: AppEpic = (action$, state$) =>
);
}
- return DataService.removeFile(bucket, payload.fileId).pipe(
+ return FileService.removeFile(payload.fileId).pipe(
map(() => {
return FilesActions.removeFileSuccess({
fileId: payload.fileId,
@@ -262,7 +212,9 @@ const unselectFilesEpic: AppEpic = (action$, state$) =>
switchMap(({ payload }) => {
const files = FilesSelectors.selectFilesByIds(state$.value, payload.ids);
const cancelFileActions = files
- .filter((file) => !file.serverSynced && file.status === 'UPLOADING')
+ .filter(
+ (file) => !file.serverSynced && file.status === UploadStatus.LOADING,
+ )
.map((file) => of(FilesActions.uploadFileCancel({ id: file.id })));
return cancelFileActions.length ? concat(...cancelFileActions) : EMPTY;
@@ -278,7 +230,7 @@ const downloadFilesListEpic: AppEpic = (action$, state$) =>
tap(({ files }) => {
files.forEach((file) =>
triggerDownload(
- `api/files/file/${encodeURI(`${file.absolutePath}/${file.name}`)}`,
+ `api/${encodeURI(`${file.absolutePath}/${file.name}`)}`,
file.name,
),
);
@@ -287,7 +239,6 @@ const downloadFilesListEpic: AppEpic = (action$, state$) =>
);
export const FilesEpics = combineEpics(
- initEpic,
uploadFileEpic,
getFileFoldersEpic,
getFilesEpic,
@@ -298,6 +249,5 @@ export const FilesEpics = combineEpics(
removeMultipleFilesEpic,
downloadFilesListEpic,
removeFileFailEpic,
- getBucketEpic,
unselectFilesEpic,
);
diff --git a/apps/chat/src/store/files/files.reducers.ts b/apps/chat/src/store/files/files.reducers.ts
index 4c9c9955e7..6c6d667907 100644
--- a/apps/chat/src/store/files/files.reducers.ts
+++ b/apps/chat/src/store/files/files.reducers.ts
@@ -6,51 +6,38 @@ import {
getParentAndChildFolders,
} from '@/src/utils/app/folders';
-import { DialFile, FileFolderInterface, Status } from '@/src/types/files';
+import { UploadStatus } from '@/src/types/common';
+import { DialFile, FileFolderInterface } from '@/src/types/files';
import { FolderType } from '@/src/types/folder';
+import { DEFAULT_FOLDER_NAME } from '@/src/constants/default-settings';
+
import { RootState } from '../index';
export interface FilesState {
files: DialFile[];
- bucket: string;
selectedFilesIds: string[];
- filesStatus: Status;
+ filesStatus: UploadStatus;
folders: FileFolderInterface[];
- foldersStatus: Status;
- loadingFolder: string | undefined;
- newAddedFolderId: string | undefined;
+ foldersStatus: UploadStatus;
+ loadingFolder?: string;
+ newAddedFolderId?: string;
}
const initialState: FilesState = {
files: [],
- bucket: '',
- filesStatus: undefined,
+ filesStatus: UploadStatus.UNINITIALIZED,
selectedFilesIds: [],
folders: [],
- foldersStatus: undefined,
- loadingFolder: undefined,
- newAddedFolderId: undefined,
+ foldersStatus: UploadStatus.UNINITIALIZED,
};
export const filesSlice = createSlice({
name: 'files',
initialState,
reducers: {
- init: (state) => state,
- getBucket: (state) => state,
- setBucket: (
- state,
- {
- payload,
- }: PayloadAction<{
- bucket: string;
- }>,
- ) => {
- state.bucket = payload.bucket;
- },
uploadFile: (
state,
{
@@ -69,7 +56,7 @@ export const filesSlice = createSlice({
relativePath: payload.relativePath,
folderId: payload.relativePath || undefined,
- status: 'UPLOADING',
+ status: UploadStatus.LOADING,
percent: 0,
fileContent: payload.fileContent,
contentLength: payload.fileContent.size,
@@ -88,7 +75,7 @@ export const filesSlice = createSlice({
return state;
}
- file.status = 'UPLOADING';
+ file.status = UploadStatus.LOADING;
file.percent = 0;
},
selectFiles: (state, { payload }: PayloadAction<{ ids: string[] }>) => {
@@ -140,7 +127,7 @@ export const filesSlice = createSlice({
) => {
const updatedFile = state.files.find((file) => file.id === payload.id);
if (updatedFile) {
- updatedFile.status = 'FAILED';
+ updatedFile.status = UploadStatus.FAILED;
}
},
getFiles: (
@@ -149,7 +136,7 @@ export const filesSlice = createSlice({
path?: string;
}>,
) => {
- state.filesStatus = 'LOADING';
+ state.filesStatus = UploadStatus.LOADING;
},
getFilesSuccess: (
state,
@@ -165,10 +152,10 @@ export const filesSlice = createSlice({
(file) => file.relativePath !== payload.relativePath,
),
);
- state.filesStatus = 'LOADED';
+ state.filesStatus = UploadStatus.LOADED;
},
getFilesFail: (state) => {
- state.filesStatus = 'FAILED';
+ state.filesStatus = UploadStatus.FAILED;
},
getFolders: (
state,
@@ -178,7 +165,7 @@ export const filesSlice = createSlice({
path?: string;
}>,
) => {
- state.foldersStatus = 'LOADING';
+ state.foldersStatus = UploadStatus.LOADING;
state.loadingFolder = payload.path;
},
getFoldersList: (
@@ -196,7 +183,7 @@ export const filesSlice = createSlice({
}>,
) => {
state.loadingFolder = undefined;
- state.foldersStatus = 'LOADED';
+ state.foldersStatus = UploadStatus.LOADED;
state.folders = payload.folders.concat(
state.folders.filter(
(folder) =>
@@ -208,7 +195,7 @@ export const filesSlice = createSlice({
},
getFoldersFail: (state) => {
state.loadingFolder = undefined;
- state.foldersStatus = 'FAILED';
+ state.foldersStatus = UploadStatus.FAILED;
},
getFilesWithFolders: (
state,
@@ -226,7 +213,7 @@ export const filesSlice = createSlice({
) => {
const folderName = getAvailableNameOnSameFolderLevel(
state.folders,
- 'New folder',
+ DEFAULT_FOLDER_NAME,
payload.relativePath,
);
@@ -334,7 +321,8 @@ const selectSelectedFiles = createSelector(
);
const selectIsUploadingFilePresent = createSelector(
[selectSelectedFiles],
- (selectedFiles) => selectedFiles.some((file) => file.status === 'UPLOADING'),
+ (selectedFiles) =>
+ selectedFiles.some((file) => file.status === UploadStatus.LOADING),
);
const selectFolders = createSelector([rootSelector], (state) => {
@@ -342,18 +330,15 @@ const selectFolders = createSelector([rootSelector], (state) => {
a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1,
);
});
-const selectFoldersStatus = createSelector([rootSelector], (state) => {
- return state.foldersStatus;
+const selectAreFoldersLoading = createSelector([rootSelector], (state) => {
+ return state.foldersStatus === UploadStatus.LOADING;
});
-const selectLoadingFolderId = createSelector([rootSelector], (state) => {
- return state.loadingFolder;
+const selectLoadingFolderIds = createSelector([rootSelector], (state) => {
+ return state.loadingFolder ? [state.loadingFolder] : [];
});
const selectNewAddedFolderId = createSelector([rootSelector], (state) => {
return state.newAddedFolderId;
});
-const selectBucket = createSelector([rootSelector], (state) => {
- return state.bucket;
-});
const selectFoldersWithSearchTerm = createSelector(
[selectFolders, (_state, searchTerm: string) => searchTerm],
(folders, searchTerm) => {
@@ -371,11 +356,10 @@ export const FilesSelectors = {
selectSelectedFiles,
selectIsUploadingFilePresent,
selectFolders,
- selectFoldersStatus,
- selectLoadingFolderId,
+ selectAreFoldersLoading,
+ selectLoadingFolderIds,
selectNewAddedFolderId,
selectFilesByIds,
- selectBucket,
selectFoldersWithSearchTerm,
};
diff --git a/apps/chat/src/store/import-export/importExport.epics.ts b/apps/chat/src/store/import-export/importExport.epics.ts
index 8834381f27..eb01aa1315 100644
--- a/apps/chat/src/store/import-export/importExport.epics.ts
+++ b/apps/chat/src/store/import-export/importExport.epics.ts
@@ -17,7 +17,8 @@ import {
import { combineEpics } from 'redux-observable';
-import { DataService } from '@/src/utils/app/data/data-service';
+import { BucketService } from '@/src/utils/app/data/bucket-service';
+import { FileService } from '@/src/utils/app/data/file-service';
import { getConversationAttachmentWithPath } from '@/src/utils/app/folders';
import {
ImportConversationsResponse,
@@ -45,7 +46,7 @@ import {
ConversationsSelectors,
} from '../conversations/conversations.reducers';
import { getUniqueAttachments } from '../conversations/conversations.selectors';
-import { FilesActions, FilesSelectors } from '../files/files.reducers';
+import { FilesActions } from '../files/files.reducers';
import { selectFolders } from '../prompts/prompts.selectors';
import {
ImportExportActions,
@@ -61,9 +62,9 @@ const exportConversationEpic: AppEpic = (action$, state$) =>
conversation: ConversationsSelectors.selectConversation(
state$.value,
payload.conversationId,
- ),
+ ) as Conversation, //TODO: fix in https://github.com/epam/ai-dial-chat/issues/640
withAttachments: payload.withAttachments,
- bucket: FilesSelectors.selectBucket(state$.value),
+ bucket: BucketService.getBucket(),
})),
switchMap(({ conversation, withAttachments, bucket }) => {
if (!conversation) {
@@ -109,7 +110,9 @@ const exportConversationsEpic: AppEpic = (action$, state$) =>
action$.pipe(
filter(ImportExportActions.exportConversations.match),
map(() => ({
- conversations: ConversationsSelectors.selectConversations(state$.value),
+ conversations: ConversationsSelectors.selectConversations(
+ state$.value,
+ ) as Conversation[], //TODO: fix in https://github.com/epam/ai-dial-chat/issues/640
folders: ConversationsSelectors.selectFolders(state$.value),
})),
tap(({ conversations, folders }) => {
@@ -246,12 +249,12 @@ const importZipEpic: AppEpic = (action$, state$) =>
}),
);
-const uploadConversationAttachmentsEpic: AppEpic = (action$, state$) =>
+const uploadConversationAttachmentsEpic: AppEpic = (action$) =>
action$.pipe(
filter(ImportExportActions.uploadConversationAttachments.match),
switchMap(({ payload }) => {
const { attachmentsToUpload, completeHistory } = payload;
- const bucket = FilesSelectors.selectBucket(state$.value);
+ const bucket = BucketService.getBucket();
if (!bucket.length) {
return of(ImportExportActions.importFail());
@@ -270,9 +273,8 @@ const uploadConversationAttachmentsEpic: AppEpic = (action$, state$) =>
formData.append('attachment', attachment.fileContent, attachment.name);
const relativePath = `imports/${conversation.id}/${attachment.relativePath}`;
- return DataService.sendFile(
+ return FileService.sendFile(
formData,
- bucket,
relativePath,
attachment.name,
).pipe(
diff --git a/apps/chat/src/store/models/models.reducers.ts b/apps/chat/src/store/models/models.reducers.ts
index 397603e690..2624f6327f 100644
--- a/apps/chat/src/store/models/models.reducers.ts
+++ b/apps/chat/src/store/models/models.reducers.ts
@@ -2,9 +2,9 @@ import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit';
import { translate } from '@/src/utils/app/translation';
-import { EntityType } from '@/src/types/common';
+import { EntityType, UploadStatus } from '@/src/types/common';
import { ErrorMessage } from '@/src/types/error';
-import { ModelsListingStatuses, ModelsMap } from '@/src/types/models';
+import { ModelsMap } from '@/src/types/models';
import { OpenAIEntityModel } from '@/src/types/openai';
import { errorsMessages } from '@/src/constants/errors';
@@ -12,7 +12,7 @@ import { errorsMessages } from '@/src/constants/errors';
import { RootState } from '../index';
export interface ModelsState {
- status: ModelsListingStatuses;
+ status: UploadStatus;
error: ErrorMessage | undefined;
models: OpenAIEntityModel[];
modelsMap: ModelsMap;
@@ -20,7 +20,7 @@ export interface ModelsState {
}
const initialState: ModelsState = {
- status: ModelsListingStatuses.UNINITIALIZED,
+ status: UploadStatus.UNINITIALIZED,
error: undefined,
models: [],
modelsMap: {},
@@ -33,13 +33,13 @@ export const modelsSlice = createSlice({
reducers: {
init: (state) => state,
getModels: (state) => {
- state.status = ModelsListingStatuses.LOADING;
+ state.status = UploadStatus.LOADING;
},
getModelsSuccess: (
state,
{ payload }: PayloadAction<{ models: OpenAIEntityModel[] }>,
) => {
- state.status = ModelsListingStatuses.LOADED;
+ state.status = UploadStatus.LOADED;
state.error = undefined;
state.models = payload.models;
state.modelsMap = (payload.models as OpenAIEntityModel[]).reduce(
@@ -59,7 +59,7 @@ export const modelsSlice = createSlice({
error: { status?: string | number; statusText?: string };
}>,
) => {
- state.status = ModelsListingStatuses.LOADED;
+ state.status = UploadStatus.LOADED;
state.error = {
title: translate('Error fetching models.'),
code: payload.error.status?.toString() ?? 'unknown',
@@ -108,11 +108,11 @@ export const modelsSlice = createSlice({
const rootSelector = (state: RootState): ModelsState => state.models;
const selectModelsIsLoading = createSelector([rootSelector], (state) => {
- return state.status === ModelsListingStatuses.LOADING;
+ return state.status === UploadStatus.LOADING;
});
const selectIsModelsLoaded = createSelector([rootSelector], (state) => {
- return state.status === ModelsListingStatuses.LOADED;
+ return state.status === UploadStatus.LOADED;
});
const selectModelsError = createSelector([rootSelector], (state) => {
diff --git a/apps/chat/src/store/prompts/prompts.epics.ts b/apps/chat/src/store/prompts/prompts.epics.ts
index bbe2264cca..1c0809a2d6 100644
--- a/apps/chat/src/store/prompts/prompts.epics.ts
+++ b/apps/chat/src/store/prompts/prompts.epics.ts
@@ -1,28 +1,59 @@
-import { concat, filter, ignoreElements, map, of, switchMap, tap } from 'rxjs';
+import {
+ EMPTY,
+ Observable,
+ concat,
+ filter,
+ forkJoin,
+ ignoreElements,
+ map,
+ mergeMap,
+ of,
+ switchMap,
+ tap,
+ zip,
+} from 'rxjs';
+
+import { AnyAction } from '@reduxjs/toolkit';
import { combineEpics } from 'redux-observable';
-import { DataService } from '@/src/utils/app/data/data-service';
import {
+ combineEntities,
+ updateEntitiesFoldersAndIds,
+} from '@/src/utils/app/common';
+import { PromptService } from '@/src/utils/app/data/prompt-service';
+import { constructPath } from '@/src/utils/app/file';
+import {
+ addGeneratedFolderId,
findRootFromItems,
+ getAllPathsFromPath,
+ getFolderFromPath,
getFolderIdByPath,
getTemporaryFoldersToPublish,
+ splitPath,
+ updateMovedFolderId,
} from '@/src/utils/app/folders';
import {
exportPrompt,
exportPrompts,
importPrompts,
} from '@/src/utils/app/import-export';
+import { addGeneratedPromptId } from '@/src/utils/app/prompts';
import { translate } from '@/src/utils/app/translation';
+import { getPromptApiKey } from '@/src/utils/server/api';
+import { FeatureType, UploadStatus } from '@/src/types/common';
+import { FolderType } from '@/src/types/folder';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
import { AppEpic } from '@/src/types/store';
import { resetShareEntity } from '@/src/constants/chat';
import { errorsMessages } from '@/src/constants/errors';
-import { UIActions } from '../ui/ui.reducers';
+import { UIActions, UISelectors } from '../ui/ui.reducers';
import { PromptsActions, PromptsSelectors } from './prompts.reducers';
+import { RootState } from '@/src/store';
import { v4 as uuidv4 } from 'uuid';
const savePromptsEpic: AppEpic = (action$, state$) =>
@@ -30,9 +61,6 @@ const savePromptsEpic: AppEpic = (action$, state$) =>
filter(
(action) =>
PromptsActions.createNewPrompt.match(action) ||
- PromptsActions.deletePrompts.match(action) ||
- PromptsActions.clearPrompts.match(action) ||
- PromptsActions.updatePrompt.match(action) ||
PromptsActions.addPrompts.match(action) ||
PromptsActions.importPromptsSuccess.match(action) ||
PromptsActions.unpublishPrompt.match(action) ||
@@ -40,7 +68,7 @@ const savePromptsEpic: AppEpic = (action$, state$) =>
),
map(() => PromptsSelectors.selectPrompts(state$.value)),
switchMap((prompts) => {
- return DataService.setPrompts(prompts);
+ return PromptService.setPrompts(prompts);
}),
ignoreElements(),
);
@@ -51,8 +79,6 @@ const saveFoldersEpic: AppEpic = (action$, state$) =>
(action) =>
PromptsActions.createFolder.match(action) ||
PromptsActions.deleteFolder.match(action) ||
- PromptsActions.renameFolder.match(action) ||
- PromptsActions.moveFolder.match(action) ||
PromptsActions.addFolders.match(action) ||
PromptsActions.clearPrompts.match(action) ||
PromptsActions.importPromptsSuccess.match(action) ||
@@ -63,41 +89,233 @@ const saveFoldersEpic: AppEpic = (action$, state$) =>
promptsFolders: PromptsSelectors.selectFolders(state$.value),
})),
switchMap(({ promptsFolders }) => {
- return DataService.setPromptFolders(promptsFolders);
+ return PromptService.setPromptFolders(promptsFolders);
}),
ignoreElements(),
);
-const deleteFolderEpic: AppEpic = (action$, state$) =>
+const getOrUploadPrompt = (
+ payload: { id: string },
+ state: RootState,
+): Observable<{
+ prompt: Prompt | null;
+ payload: { id: string };
+}> => {
+ const prompt = PromptsSelectors.selectPrompt(state, payload.id);
+
+ if (prompt?.status !== UploadStatus.LOADED) {
+ const { name, parentPath } = splitPath(payload.id);
+ const prompt = addGeneratedPromptId({
+ name,
+ folderId: parentPath,
+ });
+
+ return forkJoin({
+ prompt: PromptService.getPrompt(prompt),
+ payload: of(payload),
+ });
+ } else {
+ return forkJoin({
+ prompt: of(prompt),
+ payload: of(payload),
+ });
+ }
+};
+
+const updatePromptEpic: AppEpic = (action$, state$) =>
action$.pipe(
- filter(PromptsActions.deleteFolder.match),
- map(({ payload }) => ({
- prompts: PromptsSelectors.selectPrompts(state$.value),
- childFolders: PromptsSelectors.selectChildAndCurrentFoldersIdsById(
- state$.value,
- payload.folderId,
- ),
- folders: PromptsSelectors.selectFolders(state$.value),
- })),
- switchMap(({ prompts, childFolders, folders }) => {
- const removedPromptsIds = prompts
- .filter(
- (prompt) => prompt.folderId && childFolders.has(prompt.folderId),
- )
- .map(({ id }) => id);
+ filter(PromptsActions.updatePrompt.match),
+ mergeMap(({ payload }) => getOrUploadPrompt(payload, state$.value)),
+ mergeMap(({ payload, prompt }) => {
+ const { values, id } = payload as {
+ id: string;
+ values: Partial;
+ };
+
+ if (!prompt) {
+ return EMPTY; // TODO: handle?
+ }
+
+ const newPrompt: Prompt = {
+ ...prompt,
+ ...values,
+ id: constructPath(
+ values.folderId || prompt.folderId,
+ getPromptApiKey({ ...prompt, ...values }),
+ ),
+ };
return concat(
+ of(PromptsActions.updatePromptSuccess({ prompt: newPrompt, id })),
+ PromptService.deletePrompt(prompt).pipe(switchMap(() => EMPTY)), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ PromptService.updatePrompt(newPrompt).pipe(switchMap(() => EMPTY)), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }),
+ );
+
+export const deletePromptEpic: AppEpic = (action$) =>
+ action$.pipe(
+ filter(PromptsActions.deletePrompt.match),
+ switchMap(({ payload }) => {
+ return PromptService.deletePrompt(payload.prompt).pipe(
+ switchMap(() => EMPTY), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }),
+ );
+
+export const clearPromptsEpic: AppEpic = (action$) =>
+ action$.pipe(
+ filter(PromptsActions.clearPrompts.match),
+ switchMap(() =>
+ concat(
+ of(PromptsActions.clearPromptsSuccess()),
+ of(PromptsActions.deleteFolder({})),
+ ),
+ ),
+ );
+
+const deletePromptsEpic: AppEpic = (action$) =>
+ action$.pipe(
+ filter(PromptsActions.deletePrompts.match),
+ map(({ payload }) => ({
+ deletePrompts: payload.promptsToRemove,
+ })),
+ switchMap(({ deletePrompts }) =>
+ concat(
of(
- PromptsActions.deletePrompts({
- promptIds: removedPromptsIds,
+ PromptsActions.deletePromptsSuccess({
+ deletePrompts,
}),
),
+ zip(deletePrompts.map((id) => PromptService.deletePrompt(id))).pipe(
+ switchMap(() => EMPTY), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ ),
+ ),
+ ),
+ );
+
+const updateFolderEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(PromptsActions.updateFolder.match),
+ switchMap(({ payload }) => {
+ const folder = getFolderFromPath(payload.folderId, FolderType.Prompt);
+ const newFolder = addGeneratedFolderId({ ...folder, ...payload.values });
+
+ if (payload.folderId === newFolder.id) {
+ return EMPTY;
+ }
+
+ return PromptService.getPrompts(payload.folderId, true).pipe(
+ switchMap((prompts) => {
+ const updateFolderId = updateMovedFolderId.bind(
+ null,
+ payload.folderId,
+ newFolder.id,
+ );
+
+ const folders = PromptsSelectors.selectFolders(state$.value);
+ const allPrompts = PromptsSelectors.selectPrompts(state$.value);
+ const openedFoldersIds = UISelectors.selectOpenedFoldersIds(
+ state$.value,
+ FeatureType.Prompt,
+ );
+
+ const { updatedFolders, updatedOpenedFoldersIds } =
+ updateEntitiesFoldersAndIds(
+ prompts,
+ folders,
+ updateFolderId,
+ openedFoldersIds,
+ );
+
+ const updatedPrompts = combineEntities(
+ allPrompts.map((prompt) =>
+ addGeneratedPromptId({
+ ...prompt,
+ folderId: updateFolderId(prompt.folderId),
+ }),
+ ),
+ prompts.map((prompt) =>
+ addGeneratedPromptId({
+ ...prompt,
+ folderId: updateFolderId(prompt.folderId),
+ }),
+ ),
+ );
+
+ const actions: Observable[] = [];
+ actions.push(
+ of(
+ PromptsActions.updateFolderSuccess({
+ folders: updatedFolders,
+ prompts: updatedPrompts,
+ }),
+ ),
+ of(
+ UIActions.setOpenedFoldersIds({
+ openedFolderIds: updatedOpenedFoldersIds,
+ featureType: FeatureType.Prompt,
+ }),
+ ),
+ );
+ if (prompts.length) {
+ prompts.forEach((prompt) => {
+ actions.push(
+ of(
+ PromptsActions.updatePrompt({
+ id: prompt.id,
+ values: {
+ folderId: updateFolderId(prompt.folderId),
+ },
+ }),
+ ),
+ );
+ });
+ }
+
+ return concat(...actions);
+ }),
+ );
+ }),
+ );
+
+const deleteFolderEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(PromptsActions.deleteFolder.match),
+ switchMap(({ payload }) =>
+ forkJoin({
+ folderId: of(payload.folderId),
+ promptsToRemove: PromptService.getPrompts(payload.folderId, true),
+ folders: of(PromptsSelectors.selectFolders(state$.value)),
+ }),
+ ),
+ switchMap(({ folderId, promptsToRemove, folders }) => {
+ const childFolders = new Set([
+ folderId,
+ ...promptsToRemove.flatMap((prompt) =>
+ getAllPathsFromPath(prompt.folderId),
+ ),
+ ]);
+ const actions: Observable[] = [];
+ actions.push(
of(
PromptsActions.setFolders({
folders: folders.filter((folder) => !childFolders.has(folder.id)),
}),
),
);
+
+ if (promptsToRemove.length) {
+ actions.push(
+ of(
+ PromptsActions.deletePrompts({
+ promptsToRemove,
+ }),
+ ),
+ );
+ }
+
+ return concat(...actions);
}),
);
@@ -109,6 +327,7 @@ const exportPromptsEpic: AppEpic = (action$, state$) =>
folders: PromptsSelectors.selectFolders(state$.value),
})),
tap(({ prompts, folders }) => {
+ //TODO: upload all prompts for export - will be implemented in https://github.com/epam/ai-dial-chat/issues/640
exportPrompts(prompts, folders);
}),
ignoreElements(),
@@ -122,6 +341,7 @@ const exportPromptEpic: AppEpic = (action$, state$) =>
),
filter(Boolean),
tap((prompt) => {
+ //TODO: upload all prompts for export - will be implemented in https://github.com/epam/ai-dial-chat/issues/640
exportPrompt(
prompt,
PromptsSelectors.selectParentFolders(state$.value, prompt.folderId),
@@ -136,7 +356,7 @@ const importPromptsEpic: AppEpic = (action$, state$) =>
map(({ payload }) => {
const prompts = PromptsSelectors.selectPrompts(state$.value);
const folders = PromptsSelectors.selectFolders(state$.value);
-
+ //TODO: save in API - will be implemented in https://github.com/epam/ai-dial-chat/issues/640
return importPrompts(payload.promptsHistory, {
currentFolders: folders,
currentPrompts: prompts,
@@ -158,32 +378,40 @@ const importPromptsEpic: AppEpic = (action$, state$) =>
}),
);
-const initFoldersEpic: AppEpic = (action$) =>
- action$.pipe(
- filter((action) => PromptsActions.initFolders.match(action)),
- switchMap(() =>
- DataService.getPromptsFolders().pipe(
- map((folders) => {
- return PromptsActions.setFolders({
- folders,
- });
- }),
- ),
- ),
- );
+// const initFoldersEpic: AppEpic = (action$) =>
+// action$.pipe(
+// filter((action) => PromptsActions.initFolders.match(action)),
+// switchMap(() =>
+// PromptService.getPromptsFolders().pipe(
+// map((folders) => {
+// return PromptsActions.setFolders({
+// folders,
+// });
+// }),
+// ),
+// ),
+// );
const initPromptsEpic: AppEpic = (action$) =>
action$.pipe(
- filter(PromptsActions.init.match),
- switchMap(() =>
- DataService.getPrompts().pipe(
- map((prompts) => {
- return PromptsActions.updatePrompts({
+ filter(PromptsActions.initPrompts.match),
+ switchMap(() => PromptService.getPrompts(undefined, true)),
+ switchMap((prompts) => {
+ return concat(
+ of(
+ PromptsActions.updatePrompts({
prompts,
- });
- }),
- ),
- ),
+ }),
+ ),
+ of(
+ PromptsActions.setFolders({
+ folders: Array.from(
+ new Set(prompts.flatMap((p) => getAllPathsFromPath(p.folderId))),
+ ).map((path) => getFolderFromPath(path, FolderType.Prompt)),
+ }),
+ ),
+ );
+ }),
);
const initEpic: AppEpic = (action$) =>
@@ -191,7 +419,7 @@ const initEpic: AppEpic = (action$) =>
filter((action) => PromptsActions.init.match(action)),
switchMap(() =>
concat(
- of(PromptsActions.initFolders()),
+ // of(PromptsActions.initFolders()),
of(PromptsActions.initPrompts()),
),
),
@@ -233,13 +461,14 @@ const shareFolderEpic: AppEpic = (action$, state$) =>
.filter(
(prompt) => prompt.folderId && childFolders.has(prompt.folderId),
)
- .map(({ folderId, ...prompt }) => ({
- ...prompt,
- ...resetShareEntity,
- id: uuidv4(),
- originalId: prompt.id,
- folderId: mapping.get(folderId),
- }));
+ .map(({ folderId, ...prompt }) =>
+ addGeneratedPromptId({
+ ...prompt,
+ ...resetShareEntity,
+ originalId: prompt.id,
+ folderId: mapping.get(folderId),
+ }),
+ );
return concat(
of(
@@ -269,16 +498,17 @@ const sharePromptEpic: AppEpic = (action$, state$) =>
switchMap(({ sharedPromptId, shareUniqueId, prompts }) => {
const sharedPrompts = prompts
.filter((prompt) => prompt.id === sharedPromptId)
- .map(({ folderId: _, ...prompt }) => ({
- ...prompt,
- ...resetShareEntity,
- id: uuidv4(),
- originalId: prompt.id,
- folderId: undefined, // show on root level
- sharedWithMe: true,
- shareUniqueId:
- prompt.id === sharedPromptId ? shareUniqueId : undefined,
- }));
+ .map(({ folderId: _, ...prompt }) =>
+ addGeneratedPromptId({
+ ...prompt,
+ ...resetShareEntity,
+ originalId: prompt.id,
+ folderId: undefined, // show on root level
+ sharedWithMe: true,
+ shareUniqueId:
+ prompt.id === sharedPromptId ? shareUniqueId : undefined,
+ }),
+ );
return concat(
of(
@@ -355,13 +585,14 @@ const publishFolderEpic: AppEpic = (action$, state$) =>
.filter(
(prompt) => prompt.folderId && childFolders.has(prompt.folderId),
)
- .map(({ folderId, ...prompt }) => ({
- ...prompt,
- ...resetShareEntity,
- id: uuidv4(),
- originalId: prompt.id,
- folderId: mapping.get(folderId),
- }));
+ .map(({ folderId, ...prompt }) =>
+ addGeneratedPromptId({
+ ...prompt,
+ ...resetShareEntity,
+ originalId: prompt.id,
+ folderId: mapping.get(folderId),
+ }),
+ );
return concat(
of(
@@ -393,20 +624,21 @@ const publishPromptEpic: AppEpic = (action$, state$) =>
switchMap(({ publishRequest, prompts, publishedAndTemporaryFolders }) => {
const sharedPrompts = prompts
.filter((prompt) => prompt.id === publishRequest.id)
- .map(({ folderId: _, ...prompt }) => ({
- ...prompt,
- ...resetShareEntity,
- id: uuidv4(),
- originalId: prompt.id,
- folderId: getFolderIdByPath(
- publishRequest.path,
- publishedAndTemporaryFolders,
- ),
- publishedWithMe: true,
- name: publishRequest.name,
- publishVersion: publishRequest.version,
- shareUniqueId: publishRequest.shareUniqueId,
- }));
+ .map(({ folderId: _, ...prompt }) =>
+ addGeneratedPromptId({
+ ...prompt,
+ ...resetShareEntity,
+ originalId: prompt.id,
+ folderId: getFolderIdByPath(
+ publishRequest.path,
+ publishedAndTemporaryFolders,
+ ),
+ publishedWithMe: true,
+ name: publishRequest.name,
+ publishVersion: publishRequest.version,
+ shareUniqueId: publishRequest.shareUniqueId,
+ }),
+ );
const rootItem = findRootFromItems(sharedPrompts);
const temporaryFolders = getTemporaryFoldersToPublish(
@@ -427,19 +659,47 @@ const publishPromptEpic: AppEpic = (action$, state$) =>
}),
);
+export const uploadPromptEpic: AppEpic = (action$, state$) =>
+ action$.pipe(
+ filter(PromptsActions.uploadPrompt.match),
+ switchMap(({ payload }) => {
+ const originalPrompt = PromptsSelectors.selectPrompt(
+ state$.value,
+ payload.promptId,
+ ) as PromptInfo;
+
+ return PromptService.getPrompt(originalPrompt).pipe(
+ map((servicePrompt) => ({ originalPrompt, servicePrompt })),
+ );
+ }),
+ map(({ servicePrompt, originalPrompt }) => {
+ return PromptsActions.uploadPromptSuccess({
+ prompt: servicePrompt,
+ originalPromptId: originalPrompt.id,
+ });
+ }),
+ );
+
export const PromptsEpics = combineEpics(
initEpic,
initPromptsEpic,
- initFoldersEpic,
+ // initFoldersEpic,
savePromptsEpic,
saveFoldersEpic,
deleteFolderEpic,
exportPromptsEpic,
exportPromptEpic,
importPromptsEpic,
+ updatePromptEpic,
+ deletePromptEpic,
+ clearPromptsEpic,
+ deletePromptsEpic,
+ updateFolderEpic,
shareFolderEpic,
sharePromptEpic,
publishFolderEpic,
publishPromptEpic,
+
+ uploadPromptEpic,
);
diff --git a/apps/chat/src/store/prompts/prompts.reducers.ts b/apps/chat/src/store/prompts/prompts.reducers.ts
index e8b89d7503..cdebb1d665 100644
--- a/apps/chat/src/store/prompts/prompts.reducers.ts
+++ b/apps/chat/src/store/prompts/prompts.reducers.ts
@@ -1,21 +1,28 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
-import { generateNextName, getNextDefaultName } from '@/src/utils/app/folders';
+import {
+ addGeneratedFolderId,
+ generateNextName,
+ getNextDefaultName,
+} from '@/src/utils/app/folders';
+import { addGeneratedPromptId } from '@/src/utils/app/prompts';
import { translate } from '@/src/utils/app/translation';
import { FolderInterface, FolderType } from '@/src/types/folder';
import { PromptsHistory } from '@/src/types/importExport';
-import { Prompt } from '@/src/types/prompt';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
import { SearchFilters } from '@/src/types/search';
import { PublishRequest } from '@/src/types/share';
import { resetShareEntity } from '@/src/constants/chat';
+import { DEFAULT_FOLDER_NAME } from '@/src/constants/default-settings';
+import * as PromptsSelectors from './prompts.selectors';
import { PromptsState } from './prompts.types';
import { v4 as uuidv4 } from 'uuid';
-export * as PromptsSelectors from './prompts.selectors';
+export { PromptsSelectors };
const initialState: PromptsState = {
prompts: [],
@@ -26,6 +33,8 @@ const initialState: PromptsState = {
selectedPromptId: undefined,
isEditModalOpen: false,
newAddedFolderId: undefined,
+ promptsLoaded: false,
+ isPromptLoading: false,
};
export const promptsSlice = createSlice({
@@ -33,35 +42,64 @@ export const promptsSlice = createSlice({
initialState,
reducers: {
init: (state) => state,
- initFolders: (state) => state,
initPrompts: (state) => state,
createNewPrompt: (state) => {
- const newPrompt: Prompt = {
- id: uuidv4(),
- name: getNextDefaultName(translate('Prompt'), state.prompts),
+ const newPrompt: Prompt = addGeneratedPromptId({
+ name: getNextDefaultName(
+ translate('Prompt'),
+ state.prompts.filter((prompt) => !prompt.folderId), // only root prompts
+ ),
description: '',
content: '',
- };
+ });
state.prompts = state.prompts.concat(newPrompt);
state.selectedPromptId = newPrompt.id;
},
deletePrompts: (
state,
- { payload }: PayloadAction<{ promptIds: string[] }>,
+ { payload }: PayloadAction<{ promptsToRemove: PromptInfo[] }>,
+ ) => {
+ const promptToDeleteIds = payload.promptsToRemove.map(
+ (prompt) => prompt.id,
+ );
+
+ state.prompts = state.prompts.filter(
+ (p) => !promptToDeleteIds.includes(p.id),
+ );
+ },
+ deletePromptsSuccess: (
+ state,
+ { payload }: PayloadAction<{ deletePrompts: PromptInfo[] }>,
) => {
+ const deleteIds = new Set(
+ payload.deletePrompts.map((prompt) => prompt.id),
+ );
+
state.prompts = state.prompts.filter(
- (p) => !payload.promptIds.includes(p.id),
+ (prompt) => !deleteIds.has(prompt.id),
+ );
+ },
+ deletePrompt: (
+ state,
+ { payload }: PayloadAction<{ prompt: PromptInfo }>,
+ ) => {
+ state.prompts = state.prompts.filter(
+ (prompt) => prompt.id !== payload.prompt.id,
);
},
updatePrompt: (
state,
- { payload }: PayloadAction<{ promptId: string; values: Partial }>,
+ _action: PayloadAction<{ id: string; values: Partial }>,
+ ) => state,
+ updatePromptSuccess: (
+ state,
+ { payload }: PayloadAction<{ prompt: Prompt; id: string }>,
) => {
state.prompts = state.prompts.map((prompt) => {
- if (prompt.id === payload.promptId) {
+ if (prompt.id === payload.id) {
return {
...prompt,
- ...payload.values,
+ ...payload.prompt,
};
}
@@ -162,7 +200,7 @@ export const promptsSlice = createSlice({
state,
{ payload }: PayloadAction<{ prompt: Prompt }>,
) => {
- const newPrompt: Prompt = {
+ const newPrompt: Prompt = addGeneratedPromptId({
...payload.prompt,
...resetShareEntity,
folderId: undefined,
@@ -171,8 +209,7 @@ export const promptsSlice = createSlice({
payload.prompt.name,
state.prompts,
),
- id: uuidv4(),
- };
+ });
state.prompts = state.prompts.concat(newPrompt);
state.selectedPromptId = newPrompt.id;
},
@@ -181,11 +218,13 @@ export const promptsSlice = createSlice({
{ payload }: PayloadAction<{ prompts: Prompt[] }>,
) => {
state.prompts = payload.prompts;
+ state.promptsLoaded = true;
},
addPrompts: (state, { payload }: PayloadAction<{ prompts: Prompt[] }>) => {
state.prompts = state.prompts.concat(payload.prompts);
},
- clearPrompts: (state) => {
+ clearPrompts: (state) => state,
+ clearPromptsSuccess: (state) => {
state.prompts = [];
state.folders = [];
},
@@ -209,15 +248,22 @@ export const promptsSlice = createSlice({
state,
{
payload,
- }: PayloadAction<{ name?: string; folderId?: string } | undefined>,
+ }: PayloadAction<{ name?: string; parentId?: string } | undefined>,
) => {
- const newFolder: FolderInterface = {
- id: payload?.folderId || uuidv4(),
+ const newFolder: FolderInterface = addGeneratedFolderId({
+ folderId: payload?.parentId,
name:
- payload?.name ?? // custom name
- getNextDefaultName(translate('New folder'), state.folders), // default name with counter
+ // custom name
+ payload?.name ??
+ // default name with counter
+ PromptsSelectors.selectNewFolderName(
+ {
+ prompts: state,
+ },
+ payload?.parentId,
+ ),
type: FolderType.Prompt,
- };
+ });
state.folders = state.folders.concat(newFolder);
},
@@ -230,7 +276,7 @@ export const promptsSlice = createSlice({
}>,
) => {
const folderName = getNextDefaultName(
- translate('New folder'),
+ translate(DEFAULT_FOLDER_NAME),
[
...state.temporaryFolders,
...state.folders.filter((folder) => folder.publishedWithMe),
@@ -250,7 +296,10 @@ export const promptsSlice = createSlice({
});
state.newAddedFolderId = id;
},
- deleteFolder: (state, { payload }: PayloadAction<{ folderId: string }>) => {
+ deleteFolder: (
+ state,
+ { payload }: PayloadAction<{ folderId?: string }>,
+ ) => {
state.folders = state.folders.filter(({ id }) => id !== payload.folderId);
},
deleteTemporaryFolder: (
@@ -264,34 +313,12 @@ export const promptsSlice = createSlice({
deleteAllTemporaryFolders: (state) => {
state.temporaryFolders = [];
},
- renameFolder: (
- state,
- { payload }: PayloadAction<{ folderId: string; name: string }>,
- ) => {
- const name = payload.name.trim();
- if (name === '') {
- return;
- }
- state.folders = state.folders.map((folder) => {
- if (folder.id === payload.folderId) {
- return {
- ...folder,
- name,
- };
- }
-
- return folder;
- });
- },
renameTemporaryFolder: (
state,
{ payload }: PayloadAction<{ folderId: string; name: string }>,
) => {
state.newAddedFolderId = undefined;
const name = payload.name.trim();
- if (name === '') {
- return;
- }
state.temporaryFolders = state.temporaryFolders.map((folder) =>
folder.id !== payload.folderId ? folder : { ...folder, name },
@@ -300,26 +327,35 @@ export const promptsSlice = createSlice({
resetNewFolderId: (state) => {
state.newAddedFolderId = undefined;
},
- moveFolder: (
+ updateFolder: (
state,
{
payload,
- }: PayloadAction<{
- folderId: string;
- newParentFolderId: string | undefined;
- }>,
+ }: PayloadAction<{ folderId: string; values: Partial }>,
) => {
state.folders = state.folders.map((folder) => {
if (folder.id === payload.folderId) {
return {
...folder,
- folderId: payload.newParentFolderId,
+ ...payload.values,
};
}
return folder;
});
},
+ updateFolderSuccess: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{
+ folders: FolderInterface[];
+ prompts: PromptInfo[];
+ }>,
+ ) => {
+ state.folders = payload.folders;
+ state.prompts = payload.prompts;
+ },
setFolders: (
state,
{ payload }: PayloadAction<{ folders: FolderInterface[] }>,
@@ -359,6 +395,29 @@ export const promptsSlice = createSlice({
{ payload }: PayloadAction<{ promptId: string | undefined }>,
) => {
state.selectedPromptId = payload.promptId;
+ state.isPromptLoading = !!payload.promptId;
+ },
+ uploadPrompt: (state, _action: PayloadAction<{ promptId: string }>) => {
+ state.isPromptLoading = true;
+ },
+ uploadPromptSuccess: (
+ state,
+ {
+ payload,
+ }: PayloadAction<{ prompt: Prompt | null; originalPromptId: string }>,
+ ) => {
+ state.isPromptLoading = false;
+ const foundPromptIdx = state.prompts.findIndex(
+ (prompt) => prompt.id === payload.prompt?.id,
+ );
+
+ if (foundPromptIdx !== -1) {
+ state.prompts[foundPromptIdx] = payload.prompt as Prompt;
+ } else {
+ state.prompts = state.prompts.filter(
+ (prompt) => prompt.id !== payload.originalPromptId,
+ );
+ }
},
},
});
diff --git a/apps/chat/src/store/prompts/prompts.selectors.ts b/apps/chat/src/store/prompts/prompts.selectors.ts
index cb418fec6a..8de0fbdd1b 100644
--- a/apps/chat/src/store/prompts/prompts.selectors.ts
+++ b/apps/chat/src/store/prompts/prompts.selectors.ts
@@ -3,20 +3,24 @@ import { createSelector } from '@reduxjs/toolkit';
import {
getChildAndCurrentFoldersIdsById,
getFilteredFolders,
+ getNextDefaultName,
getParentAndChildFolders,
getParentAndCurrentFoldersById,
} from '@/src/utils/app/folders';
import {
PublishedWithMeFilter,
- doesPromptContainSearchTerm,
+ doesPromptOrConversationContainSearchTerm,
getMyItemsFilters,
searchSectionFolders,
} from '@/src/utils/app/search';
import { isEntityExternal } from '@/src/utils/app/share';
+import { translate } from '@/src/utils/app/translation';
import { Prompt } from '@/src/types/prompt';
import { EntityFilters, SearchFilters } from '@/src/types/search';
+import { DEFAULT_FOLDER_NAME } from '@/src/constants/default-settings';
+
import { RootState } from '../index';
import { PromptsState } from './prompts.types';
@@ -35,7 +39,8 @@ export const selectFilteredPrompts = createSelector(
(prompts, filters, searchTerm?) => {
return prompts.filter(
(prompt) =>
- (!searchTerm || doesPromptContainSearchTerm(prompt, searchTerm)) &&
+ (!searchTerm ||
+ doesPromptOrConversationContainSearchTerm(prompt, searchTerm)) &&
filters.searchFilter(prompt) &&
(prompt.folderId || filters.sectionFilter(prompt)),
);
@@ -143,7 +148,7 @@ export const selectSearchedPrompts = createSelector(
[selectPrompts, selectSearchTerm],
(prompts, searchTerm) => {
return prompts.filter((prompt) =>
- doesPromptContainSearchTerm(prompt, searchTerm),
+ doesPromptOrConversationContainSearchTerm(prompt, searchTerm),
);
},
);
@@ -165,7 +170,8 @@ export const selectSelectedPrompt = createSelector(
if (!selectedPromptId) {
return undefined;
}
- return prompts.find((prompt) => prompt.id === selectedPromptId);
+
+ return prompts.find((prompt) => prompt.id === selectedPromptId) as Prompt;
},
);
@@ -209,13 +215,16 @@ export const isPublishPromptVersionUnique = createSelector(
(_state: RootState, _entityId: string, version: string) => version,
],
(state, entityId, version) => {
- const prompt = selectPrompt(state, entityId);
+ const prompt = selectPrompt(state, entityId) as Prompt; // TODO: will be fixed in https://github.com/epam/ai-dial-chat/issues/313;
if (!prompt || prompt?.publishVersion === version) return false;
- const prompts = selectPrompts(state).filter(
- (prmt) => prmt.originalId === entityId && prmt.publishVersion === version,
- );
+ const prompts = selectPrompts(state)
+ .map((prompt) => prompt as Prompt)
+ .filter(
+ (prmt) =>
+ prmt.originalId === entityId && prmt.publishVersion === version,
+ );
if (prompts.length) return false;
@@ -265,3 +274,25 @@ export const selectNewAddedFolderId = createSelector(
return state.newAddedFolderId;
},
);
+
+export const arePromptsUploaded = createSelector([rootSelector], (state) => {
+ return state.promptsLoaded;
+});
+
+export const isPromptLoading = createSelector([rootSelector], (state) => {
+ return state.isPromptLoading;
+});
+
+// default name with counter
+export const selectNewFolderName = createSelector(
+ [
+ selectFolders,
+ (_state: RootState, folderId: string | undefined) => folderId,
+ ],
+ (folders, folderId) => {
+ return getNextDefaultName(
+ translate(DEFAULT_FOLDER_NAME),
+ folders.filter((f) => f.folderId === folderId),
+ );
+ },
+);
diff --git a/apps/chat/src/store/prompts/prompts.types.ts b/apps/chat/src/store/prompts/prompts.types.ts
index 2fd4d401be..311543177f 100644
--- a/apps/chat/src/store/prompts/prompts.types.ts
+++ b/apps/chat/src/store/prompts/prompts.types.ts
@@ -1,9 +1,9 @@
import { FolderInterface } from '@/src/types/folder';
-import { Prompt } from '@/src/types/prompt';
+import { PromptInfo } from '@/src/types/prompt';
import { SearchFilters } from '@/src/types/search';
export interface PromptsState {
- prompts: Prompt[];
+ prompts: PromptInfo[];
folders: FolderInterface[];
temporaryFolders: FolderInterface[];
searchTerm: string;
@@ -11,4 +11,6 @@ export interface PromptsState {
selectedPromptId: string | undefined;
isEditModalOpen: boolean;
newAddedFolderId?: string;
+ promptsLoaded: boolean;
+ isPromptLoading: boolean;
}
diff --git a/apps/chat/src/store/settings/settings.epic.ts b/apps/chat/src/store/settings/settings.epic.ts
index 33f692fa85..c71acda15c 100644
--- a/apps/chat/src/store/settings/settings.epic.ts
+++ b/apps/chat/src/store/settings/settings.epic.ts
@@ -1,15 +1,27 @@
-import { concat, filter, first, of, switchMap, tap } from 'rxjs';
+import {
+ EMPTY,
+ catchError,
+ concat,
+ filter,
+ first,
+ map,
+ of,
+ switchMap,
+ tap,
+} from 'rxjs';
import { combineEpics } from 'redux-observable';
+import { BucketService } from '@/src/utils/app/data/bucket-service';
import { DataService } from '@/src/utils/app/data/data-service';
import { AppEpic } from '@/src/types/store';
+import { errorsMessages } from '@/src/constants/errors';
+
import { AddonsActions } from '../addons/addons.reducers';
import { AuthSelectors } from '../auth/auth.reducers';
import { ConversationsActions } from '../conversations/conversations.reducers';
-import { FilesActions } from '../files/files.reducers';
import { ModelsActions } from '../models/models.reducers';
import { PromptsActions } from '../prompts/prompts.reducers';
import { UIActions } from '../ui/ui.reducers';
@@ -31,6 +43,24 @@ const initEpic: AppEpic = (action$, state$) =>
return authStatus !== 'loading' && !shouldLogin;
}),
first(),
+ switchMap(() =>
+ BucketService.requestBucket().pipe(
+ map(({ bucket }) => BucketService.setBucket(bucket)),
+ catchError((error) => {
+ if (error.status === 401) {
+ window.location.assign('api/auth/signin');
+ return EMPTY;
+ } else {
+ return of(
+ UIActions.showToast({
+ message: errorsMessages.errorGettingUserFileBucket,
+ type: 'error',
+ }),
+ );
+ }
+ }),
+ ),
+ ),
switchMap(() =>
concat(
of(UIActions.init()),
@@ -38,7 +68,6 @@ const initEpic: AppEpic = (action$, state$) =>
of(AddonsActions.init()),
of(ConversationsActions.init()),
of(PromptsActions.init()),
- of(FilesActions.init()),
),
),
);
diff --git a/apps/chat/src/store/settings/settings.reducers.ts b/apps/chat/src/store/settings/settings.reducers.ts
index 291f1292c8..ef17c7eb43 100644
--- a/apps/chat/src/store/settings/settings.reducers.ts
+++ b/apps/chat/src/store/settings/settings.reducers.ts
@@ -19,7 +19,7 @@ export interface SettingsState {
defaultModelId: string | undefined;
defaultRecentModelsIds: string[];
defaultRecentAddonsIds: string[];
- storageType: StorageType | string;
+ storageType: StorageType;
themesHostDefined: boolean;
}
@@ -34,7 +34,7 @@ const initialState: SettingsState = {
defaultModelId: undefined,
defaultRecentModelsIds: [],
defaultRecentAddonsIds: [],
- storageType: 'browserStorage',
+ storageType: StorageType.BrowserStorage,
themesHostDefined: false,
};
@@ -138,7 +138,7 @@ const isFeatureEnabled = createSelector(
);
const isPublishingEnabled = createSelector(
- [selectEnabledFeatures, (_, featureType?: FeatureType) => featureType],
+ [selectEnabledFeatures, (_, featureType: FeatureType) => featureType],
(enabledFeatures, featureType) => {
switch (featureType) {
case FeatureType.Chat:
@@ -152,7 +152,7 @@ const isPublishingEnabled = createSelector(
);
const isSharingEnabled = createSelector(
- [selectEnabledFeatures, (_, featureType?: FeatureType) => featureType],
+ [selectEnabledFeatures, (_, featureType: FeatureType) => featureType],
(enabledFeatures, featureType) => {
switch (featureType) {
case FeatureType.Chat:
diff --git a/apps/chat/src/store/ui/ui.reducers.ts b/apps/chat/src/store/ui/ui.reducers.ts
index 14be88a516..07b36d51b7 100644
--- a/apps/chat/src/store/ui/ui.reducers.ts
+++ b/apps/chat/src/store/ui/ui.reducers.ts
@@ -1,5 +1,6 @@
import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit';
+import { FeatureType } from '@/src/types/common';
import { Theme } from '@/src/types/themes';
import { SIDEBAR_MIN_WIDTH } from '@/src/constants/default-ui-settings';
@@ -14,13 +15,19 @@ export interface UIState {
isUserSettingsOpen: boolean;
isProfileOpen: boolean;
isCompareMode: boolean;
- openedFoldersIds: string[];
+ openedFoldersIds: Record;
textOfClosedAnnouncement?: string | undefined;
isChatFullWidth: boolean;
chatbarWidth?: number;
promptbarWidth?: number;
}
+export const openFoldersInitialState = {
+ [FeatureType.Chat]: [],
+ [FeatureType.Prompt]: [],
+ [FeatureType.File]: [],
+};
+
const initialState: UIState = {
theme: '',
availableThemes: [],
@@ -29,7 +36,7 @@ const initialState: UIState = {
isUserSettingsOpen: false,
isProfileOpen: false,
isCompareMode: false,
- openedFoldersIds: [],
+ openedFoldersIds: openFoldersInitialState,
textOfClosedAnnouncement: undefined,
chatbarWidth: SIDEBAR_MIN_WIDTH,
promptbarWidth: SIDEBAR_MIN_WIDTH,
@@ -99,31 +106,50 @@ export const uiSlice = createSlice({
) => state,
setOpenedFoldersIds: (
state,
- { payload }: PayloadAction,
+ {
+ payload,
+ }: PayloadAction<{ openedFolderIds: string[]; featureType: FeatureType }>,
) => {
- const uniqueIds = Array.from(new Set(payload));
- state.openedFoldersIds = uniqueIds;
+ state.openedFoldersIds = {
+ ...state.openedFoldersIds,
+ [payload.featureType]: Array.from(new Set(payload.openedFolderIds)),
+ };
},
- toggleFolder: (state, { payload }: PayloadAction<{ id: string }>) => {
- const isOpened = state.openedFoldersIds.includes(payload.id);
+ toggleFolder: (
+ state,
+ { payload }: PayloadAction<{ id: string; featureType: FeatureType }>,
+ ) => {
+ const featureType = payload.featureType;
+ const openedFoldersIds = state.openedFoldersIds[featureType];
+ const isOpened = openedFoldersIds.includes(payload.id);
if (isOpened) {
- state.openedFoldersIds = state.openedFoldersIds.filter(
+ state.openedFoldersIds[featureType] = openedFoldersIds.filter(
(id) => id !== payload.id,
);
} else {
- state.openedFoldersIds.push(payload.id);
+ state.openedFoldersIds[featureType].push(payload.id);
}
},
- openFolder: (state, { payload }: PayloadAction<{ id: string }>) => {
- const isOpened = state.openedFoldersIds.includes(payload.id);
+ openFolder: (
+ state,
+ { payload }: PayloadAction<{ id: string; featureType: FeatureType }>,
+ ) => {
+ const featureType = payload.featureType;
+ const openedFoldersIds = state.openedFoldersIds[featureType];
+ const isOpened = openedFoldersIds.includes(payload.id);
if (!isOpened) {
- state.openedFoldersIds.push(payload.id);
+ state.openedFoldersIds[featureType].push(payload.id);
}
},
- closeFolder: (state, { payload }: PayloadAction<{ id: string }>) => {
- const isOpened = state.openedFoldersIds.includes(payload.id);
+ closeFolder: (
+ state,
+ { payload }: PayloadAction<{ id: string; featureType: FeatureType }>,
+ ) => {
+ const featureType = payload.featureType;
+ const openedFoldersIds = state.openedFoldersIds[featureType];
+ const isOpened = openedFoldersIds.includes(payload.id);
if (isOpened) {
- state.openedFoldersIds = state.openedFoldersIds.filter(
+ state.openedFoldersIds[featureType] = openedFoldersIds.filter(
(id) => id !== payload.id,
);
}
@@ -166,11 +192,25 @@ const selectIsCompareMode = createSelector([rootSelector], (state) => {
return state.isCompareMode;
});
-const selectOpenedFoldersIds = createSelector([rootSelector], (state) => {
+const selectAllOpenedFoldersIds = createSelector([rootSelector], (state) => {
return state.openedFoldersIds;
});
+
+const selectOpenedFoldersIds = createSelector(
+ [
+ selectAllOpenedFoldersIds,
+ (_state, featureType: FeatureType) => featureType,
+ ],
+ (openedFoldersIds, featureType) => {
+ return openedFoldersIds[featureType];
+ },
+);
const selectIsFolderOpened = createSelector(
- [selectOpenedFoldersIds, (_state, id: string) => id],
+ [
+ (state, featureType: FeatureType) =>
+ selectOpenedFoldersIds(state, featureType),
+ (_state, _featureType: FeatureType, id: string) => id,
+ ],
(ids, id): boolean => {
return ids.includes(id);
},
@@ -203,6 +243,7 @@ export const UISelectors = {
selectIsUserSettingsOpen,
selectIsProfileOpen,
selectIsCompareMode,
+ selectAllOpenedFoldersIds,
selectOpenedFoldersIds,
selectIsFolderOpened,
selectTextOfClosedAnnouncement,
diff --git a/apps/chat/src/styles/globals.css b/apps/chat/src/styles/globals.css
index c65e7faf7c..4f929fc813 100644
--- a/apps/chat/src/styles/globals.css
+++ b/apps/chat/src/styles/globals.css
@@ -123,7 +123,7 @@ pre:has(div.codeblock) {
}
.gradient-top-bottom {
- @apply border-transparent bg-gradient-to-b from-transparent via-layer-1 via-30% to-layer-1;
+ @apply border-transparent bg-gradient-to-b from-transparent via-layer-1 via-[25px] to-layer-1;
}
}
diff --git a/apps/chat/src/types/chat.ts b/apps/chat/src/types/chat.ts
index f791ad17c6..f53185dcd8 100644
--- a/apps/chat/src/types/chat.ts
+++ b/apps/chat/src/types/chat.ts
@@ -1,4 +1,4 @@
-import { ShareEntity } from './common';
+import { Entity, ShareEntity } from './common';
import { MIMEType } from './files';
export interface Attachment {
@@ -68,9 +68,8 @@ export interface RateBody {
value: boolean;
}
-export interface Conversation extends ShareEntity {
+export interface Conversation extends ShareEntity, ConversationInfo {
messages: Message[];
- model: ConversationEntityModel;
prompt: string;
temperature: number;
replay: Replay;
@@ -79,7 +78,6 @@ export interface Conversation extends ShareEntity {
// Addons selected by user clicks
selectedAddons: string[];
assistantModelId?: string;
- lastActivityDate?: number;
isMessageStreaming: boolean;
isNameChanged?: boolean;
@@ -112,3 +110,10 @@ export interface ConversationsTemporarySettings {
export interface ConversationEntityModel {
id: string;
}
+
+export interface ConversationInfo extends Entity {
+ model: ConversationEntityModel;
+ lastActivityDate?: number;
+ isPlayback?: boolean;
+ isReplay?: boolean;
+}
diff --git a/apps/chat/src/types/common.ts b/apps/chat/src/types/common.ts
index 69cc92b339..988e2b027a 100644
--- a/apps/chat/src/types/common.ts
+++ b/apps/chat/src/types/common.ts
@@ -10,12 +10,81 @@ export enum EntityType {
export enum FeatureType {
Chat = 'chat',
Prompt = 'prompt',
+ File = 'file',
+}
+
+export enum BackendDataNodeType {
+ ITEM = 'ITEM',
+ FOLDER = 'FOLDER',
+}
+
+export enum BackendResourceType {
+ FILE = 'FILE',
+ PROMPT = 'PROMPT',
+ CONVERSATION = 'CONVERSATION',
}
export interface Entity {
id: string;
name: string;
folderId?: string;
+ status?: UploadStatus;
}
export interface ShareEntity extends Entity, ShareInterface {}
+
+export interface BackendDataEntity {
+ name: string;
+ resourceType: BackendResourceType;
+ bucket: string;
+ parentPath?: string | null;
+ url: string;
+}
+
+export interface BackendEntity extends BackendDataEntity {
+ nodeType: BackendDataNodeType.ITEM;
+}
+
+export interface BackendChatEntity extends BackendEntity {
+ updatedAt: number;
+}
+
+export interface BackendFolder extends BackendDataEntity {
+ nodeType: BackendDataNodeType.FOLDER;
+ items: ItemType[];
+}
+
+export type BackendChatFolder = BackendFolder<
+ BackendChatEntity | BackendChatFolder
+>;
+
+export interface BaseDialEntity {
+ // Combination of relative path and name
+ id: string;
+ // Only for files fetched uploaded to backend
+ // Same as relative path but has some absolute prefix like
+ absolutePath?: string;
+ relativePath?: string;
+ // Same as relative path, but needed for simplicity and backward compatibility
+ folderId?: string;
+ serverSynced?: boolean;
+ status?: UploadStatus.LOADING | UploadStatus.FAILED;
+}
+
+export type DialChatEntity = Omit<
+ BackendChatEntity,
+ 'path' | 'nodeType' | 'resourceType' | 'bucket' | 'parentPath' | 'url'
+> &
+ BaseDialEntity;
+
+export enum UploadStatus {
+ UNINITIALIZED = 'UNINITIALIZED',
+ LOADING = 'UPLOADING',
+ LOADED = 'LOADED',
+ FAILED = 'FAILED',
+ ALL_LOADED = 'ALL_LOADED',
+}
+
+export const isNotLoaded = (status?: UploadStatus) => {
+ return !status || status === UploadStatus.UNINITIALIZED;
+};
diff --git a/apps/chat/src/types/files.ts b/apps/chat/src/types/files.ts
index 14af6955d3..6ae56c9858 100644
--- a/apps/chat/src/types/files.ts
+++ b/apps/chat/src/types/files.ts
@@ -1,3 +1,9 @@
+import {
+ BackendEntity,
+ BackendFolder,
+ BaseDialEntity,
+} from '@/src/types/common';
+
import { FolderInterface } from './folder';
export type ImageMIMEType = 'image/jpeg' | 'image/png' | string;
@@ -9,47 +15,21 @@ export type MIMEType =
| ImageMIMEType
| string;
-export enum BackendDataNodeType {
- ITEM = 'ITEM',
- FOLDER = 'FOLDER',
-}
-
-interface BackendDataEntity {
- name: string;
- nodeType: BackendDataNodeType;
- resourceType: 'FILE'; // only 1 type for now
- bucket: string;
- parentPath: string | null | undefined;
-}
-
-export interface BackendFile extends BackendDataEntity {
- nodeType: BackendDataNodeType.ITEM;
+export interface BackendFile extends BackendEntity {
contentLength: number;
contentType: MIMEType;
}
-export interface BackendFileFolder extends BackendDataEntity {
- nodeType: BackendDataNodeType.FOLDER;
- items: (BackendFile | BackendFileFolder)[];
-}
+
+export type BackendFileFolder = BackendFolder;
export type DialFile = Omit<
BackendFile,
- 'path' | 'nodeType' | 'resourceType' | 'bucket' | 'parentPath'
-> & {
- // Combination of relative path and name
- id: string;
- // Only for files fetched uploaded to backend
- // Same as relative path but has some absolute prefix like
- absolutePath?: string;
- relativePath?: string;
- // Same as relative path, but needed for simplicity and backward compatibility
- folderId?: string;
-
- status?: 'UPLOADING' | 'FAILED';
- percent?: number;
- fileContent?: File;
- serverSynced?: boolean;
-};
+ 'path' | 'nodeType' | 'resourceType' | 'bucket' | 'parentPath' | 'url'
+> &
+ BaseDialEntity & {
+ percent?: number;
+ fileContent?: File;
+ };
// For file folders folderId is relative path and id is relative path + '/' + name
export type FileFolderInterface = FolderInterface & {
diff --git a/apps/chat/src/types/folder.ts b/apps/chat/src/types/folder.ts
index d1097b381b..60ee4eaada 100644
--- a/apps/chat/src/types/folder.ts
+++ b/apps/chat/src/types/folder.ts
@@ -7,6 +7,11 @@ export interface FolderInterface extends ShareEntity {
serverSynced?: boolean;
}
+export interface FoldersAndEntities {
+ folders: FolderInterface[];
+ entities: T[];
+}
+
export enum FolderType {
Chat = 'chat',
Prompt = 'prompt',
@@ -23,3 +28,8 @@ export interface FolderSectionProps {
showEmptyFolders?: boolean;
openByDefault?: boolean;
}
+
+export interface MoveToFolderProps {
+ folderId?: string;
+ isNewFolder?: boolean;
+}
diff --git a/apps/chat/src/types/menu.ts b/apps/chat/src/types/menu.ts
index 872f341346..020b36c657 100644
--- a/apps/chat/src/types/menu.ts
+++ b/apps/chat/src/types/menu.ts
@@ -22,15 +22,16 @@ export interface DisplayMenuItemProps {
customTriggerData?: unknown;
className?: string;
childMenuItems?: DisplayMenuItemProps[];
+ onChildMenuOpenChange?: (isOpen: boolean) => void;
}
export type MenuItemRendererProps = DisplayMenuItemProps & {
- featureType?: FeatureType;
+ featureType: FeatureType;
};
export interface MenuProps {
menuItems: DisplayMenuItemProps[];
- featureType?: FeatureType;
+ featureType: FeatureType;
displayMenuItemCount?: number;
className?: string;
disabled?: boolean;
diff --git a/apps/chat/src/types/models.ts b/apps/chat/src/types/models.ts
index 14366e3ddd..4f558021cf 100644
--- a/apps/chat/src/types/models.ts
+++ b/apps/chat/src/types/models.ts
@@ -1,9 +1,3 @@
import { OpenAIEntityModel } from './openai';
export type ModelsMap = Partial>;
-
-export const enum ModelsListingStatuses {
- UNINITIALIZED = 'UNINITIALIZED',
- LOADING = 'LOADING',
- LOADED = 'LOADED',
-}
diff --git a/apps/chat/src/types/prompt.ts b/apps/chat/src/types/prompt.ts
index 52a3d477a4..6d7955484d 100644
--- a/apps/chat/src/types/prompt.ts
+++ b/apps/chat/src/types/prompt.ts
@@ -1,6 +1,8 @@
-import { ShareEntity } from './common';
+import { Entity, ShareEntity } from './common';
-export interface Prompt extends ShareEntity {
+export type PromptInfo = Entity;
+
+export interface Prompt extends ShareEntity, PromptInfo {
description?: string;
content?: string;
}
diff --git a/apps/chat/src/types/storage.ts b/apps/chat/src/types/storage.ts
index 1a5f98515e..36e0447490 100644
--- a/apps/chat/src/types/storage.ts
+++ b/apps/chat/src/types/storage.ts
@@ -1,10 +1,15 @@
import { Observable } from 'rxjs';
-import { Conversation } from './chat';
-import { FolderInterface } from './folder';
-import { Prompt } from './prompt';
+import { Conversation } from '@/src/types/chat';
-export type StorageType = 'browserStorage' | 'api' | 'apiMock';
+import { ConversationInfo } from './chat';
+import { FolderInterface, FoldersAndEntities } from './folder';
+import { Prompt, PromptInfo } from './prompt';
+
+export enum StorageType {
+ BrowserStorage = 'browserStorage',
+ API = 'api',
+}
export enum UIStorageKeys {
Prompts = 'prompts',
@@ -20,16 +25,79 @@ export enum UIStorageKeys {
PromptbarWidth = 'promptbarWidth',
IsChatFullWidth = 'isChatFullWidth',
OpenedFoldersIds = 'openedFoldersIds',
+ OpenedConversationFoldersIds = 'openedConversationFoldersIds',
+ OpenedPromptFoldersIds = 'openedPromptFoldersIds',
TextOfClosedAnnouncement = 'textOfClosedAnnouncement',
}
+
+export interface EntityStorage<
+ EntityInfo extends { folderId?: string },
+ Entity extends EntityInfo,
+> {
+ getFolders(path?: string): Observable; // listing with short information
+
+ getEntities(path?: string, recursive?: boolean): Observable; // listing with short information
+
+ getFoldersAndEntities(
+ path?: string,
+ ): Observable>;
+
+ getEntity(info: EntityInfo): Observable;
+
+ createEntity(entity: Entity): Observable;
+
+ updateEntity(entity: Entity): Observable;
+
+ deleteEntity(info: EntityInfo): Observable;
+
+ getEntityKey(info: EntityInfo): string;
+
+ parseEntityKey(key: string): EntityInfo;
+
+ getStorageKey(): string; // e.g. ApiKeys or `conversationHistory`/`prompts` in case of localStorage
+}
+
export interface DialStorage {
- getConversationsFolders(): Observable;
+ getConversationsFolders(path?: string): Observable;
+
setConversationsFolders(folders: FolderInterface[]): Observable;
+
getPromptsFolders(): Observable;
+
setPromptsFolders(folders: FolderInterface[]): Observable;
- getConversations(): Observable;
+ getConversationsAndFolders(
+ path?: string,
+ ): Observable>;
+
+ getConversations(
+ path?: string,
+ recursive?: boolean,
+ ): Observable;
+
+ getConversation(info: ConversationInfo): Observable;
+
+ createConversation(conversation: Conversation): Observable;
+
+ updateConversation(conversation: Conversation): Observable;
+
+ deleteConversation(info: ConversationInfo): Observable;
+
setConversations(conversations: Conversation[]): Observable;
- getPrompts(): Observable;
+
+ getPromptsAndFolders(
+ path?: string,
+ ): Observable>;
+
+ getPrompts(path?: string, recursive?: boolean): Observable;
+
+ getPrompt(info: PromptInfo): Observable;
+
+ createPrompt(prompt: Prompt): Observable;
+
+ updatePrompt(prompt: Prompt): Observable;
+
+ deletePrompt(info: PromptInfo): Observable;
+
setPrompts(prompts: Prompt[]): Observable;
}
diff --git a/apps/chat/src/utils/app/__tests__/folders.test.ts b/apps/chat/src/utils/app/__tests__/folders.test.ts
index 45bc94c59d..0aad8f0f19 100644
--- a/apps/chat/src/utils/app/__tests__/folders.test.ts
+++ b/apps/chat/src/utils/app/__tests__/folders.test.ts
@@ -1,6 +1,10 @@
import { FolderType } from '@/src/types/folder';
-import { getFolderIdByPath } from '../folders';
+import {
+ getFolderIdByPath,
+ updateMovedEntityId,
+ updateMovedFolderId,
+} from '../folders';
describe('Folder utility methods', () => {
it.each([
@@ -20,4 +24,53 @@ describe('Folder utility methods', () => {
];
expect(getFolderIdByPath(path, folders)).toBe(expectedFolderId);
});
+
+ it.each([
+ [undefined, 'f1', undefined, 'f1'],
+ ['f1', 'f2', 'f1', 'f2'],
+ ['f1', undefined, 'f1', undefined],
+ ['f1', undefined, 'f1/f2', 'f2'],
+ ['f1', undefined, 'f1/f1/f1', 'f1/f1'],
+ [undefined, undefined, 'f1/f1/f1', 'f1/f1/f1'],
+ [undefined, 'f3', 'f1/f1/f1', 'f1/f1/f1'],
+ ['f2', undefined, 'f1/f1/f1', 'f1/f1/f1'],
+ ['f2', 'f3', 'f1/f1/f1', 'f1/f1/f1'],
+ ])(
+ 'updateMovedFolderId (%s, %s, %s, %s)',
+ (
+ oldParentFolderId: string | undefined,
+ newParentFolderId: string | undefined,
+ currentId: string | undefined,
+ expectedFolderId: string | undefined,
+ ) => {
+ expect(
+ updateMovedFolderId(oldParentFolderId, newParentFolderId, currentId),
+ ).toBe(expectedFolderId);
+ },
+ );
+
+ it.each([
+ ['f1', 'f2', 'f1', 'f1'],
+ ['f1', 'f2', 'f1/f1', 'f2/f1'],
+ ['f1/f1', 'f2', 'f1/f1/f1', 'f2/f1'],
+ ['f1', undefined, 'f1', 'f1'],
+ ['f1', undefined, 'f1/f2', 'f2'],
+ ['f1', undefined, 'f1/f1/f1', 'f1/f1'],
+ [undefined, undefined, 'f1/f1/f1', 'f1/f1/f1'],
+ [undefined, 'f3', 'f1/f1/f1', 'f1/f1/f1'],
+ ['f2', undefined, 'f1/f1/f1', 'f1/f1/f1'],
+ ['f2', 'f3', 'f1/f1/f1', 'f1/f1/f1'],
+ ])(
+ 'updateMovedEntityId (%s, %s, %s, %s)',
+ (
+ oldParentFolderId: string | undefined,
+ newParentFolderId: string | undefined,
+ currentId: string,
+ expectedFolderId: string,
+ ) => {
+ expect(
+ updateMovedEntityId(oldParentFolderId, newParentFolderId, currentId),
+ ).toBe(expectedFolderId);
+ },
+ );
});
diff --git a/apps/chat/src/utils/app/__tests__/importExports.test.ts b/apps/chat/src/utils/app/__tests__/importExports.test.ts
index 75f512f5af..2a4274e3c1 100644
--- a/apps/chat/src/utils/app/__tests__/importExports.test.ts
+++ b/apps/chat/src/utils/app/__tests__/importExports.test.ts
@@ -110,7 +110,7 @@ describe('cleanData Functions', () => {
assistantModelId: 'gpt-4',
isMessageStreaming: false,
folderId: undefined,
- lastActivityDate: undefined,
+ lastActivityDate: expect.any(Number),
};
describe('cleaning v1 data', () => {
diff --git a/apps/chat/src/utils/app/attachments.ts b/apps/chat/src/utils/app/attachments.ts
index 5564362fa2..373eccf705 100644
--- a/apps/chat/src/utils/app/attachments.ts
+++ b/apps/chat/src/utils/app/attachments.ts
@@ -4,9 +4,7 @@ export const getMappedAttachmentUrl = (url: string | undefined) => {
if (!url) {
return undefined;
}
- return url.startsWith('//') || url.startsWith('http')
- ? url
- : `api/files/file/${url}`;
+ return url.startsWith('//') || url.startsWith('http') ? url : `api/${url}`;
};
export const getMappedAttachment = (attachment: Attachment): Attachment => {
diff --git a/apps/chat/src/utils/app/clean.ts b/apps/chat/src/utils/app/clean.ts
index 363360cda2..d4953cbb02 100644
--- a/apps/chat/src/utils/app/clean.ts
+++ b/apps/chat/src/utils/app/clean.ts
@@ -54,8 +54,49 @@ const migrateMessageAttachmentUrls = (message: Message): Message => {
};
};
+export const cleanConversation = (
+ conversation: Partial,
+): Conversation => {
+ // added model for each conversation (3/20/23)
+ // added system prompt for each conversation (3/21/23)
+ // added folders (3/23/23)
+ // added prompts (3/26/23)
+ // added messages (4/16/23)
+ // added replay (6/22/2023)
+ // added selectedAddons and refactored to not miss any new fields (7/6/2023)
+
+ const model: ConversationEntityModel = conversation.model
+ ? {
+ id: conversation.model.id,
+ }
+ : { id: OpenAIEntityModelID.GPT_3_5_AZ };
+
+ const assistantModelId =
+ conversation.assistantModelId ?? DEFAULT_ASSISTANT_SUBMODEL.id;
+
+ const cleanConversation: Conversation = {
+ id: conversation.id || v4(),
+ name: conversation.name || DEFAULT_CONVERSATION_NAME,
+ model: model,
+ prompt: conversation.prompt || DEFAULT_SYSTEM_PROMPT,
+ temperature: conversation.temperature ?? DEFAULT_TEMPERATURE,
+ folderId: conversation.folderId || undefined,
+ messages: conversation.messages?.map(migrateMessageAttachmentUrls) || [],
+ replay: conversation.replay || defaultReplay,
+ selectedAddons: conversation.selectedAddons ?? [],
+ assistantModelId,
+ lastActivityDate: conversation.lastActivityDate || Date.now(),
+ isMessageStreaming: false,
+ ...(conversation.playback && {
+ playback: conversation.playback,
+ }),
+ };
+
+ return cleanConversation;
+};
+
export const cleanConversationHistory = (
- history: Conversation[] | unknown,
+ history: Conversation[],
): Conversation[] => {
// added model for each conversation (3/20/23)
// added system prompt for each conversation (3/21/23)
@@ -73,40 +114,9 @@ export const cleanConversationHistory = (
return history.reduce(
(acc: Conversation[], conversation: Partial) => {
try {
- const model: ConversationEntityModel = conversation.model
- ? {
- id: conversation.model.id,
- }
- : { id: OpenAIEntityModelID.GPT_3_5_AZ };
-
- const assistantModelId =
- conversation.assistantModelId ?? DEFAULT_ASSISTANT_SUBMODEL.id;
-
- const cleanConversation: Conversation = {
- id: conversation.id || v4(),
- name: conversation.name || DEFAULT_CONVERSATION_NAME,
- model: model,
- prompt: conversation.prompt || DEFAULT_SYSTEM_PROMPT,
- temperature: conversation.temperature ?? DEFAULT_TEMPERATURE,
- folderId: conversation.folderId || undefined,
- messages:
- conversation.messages?.map(migrateMessageAttachmentUrls) || [],
- replay: conversation.replay || defaultReplay,
- selectedAddons: conversation.selectedAddons ?? [],
- assistantModelId,
- lastActivityDate: conversation.lastActivityDate,
- isMessageStreaming: false,
- ...(conversation.playback && {
- playback: conversation.playback,
- }),
- isShared: conversation.isShared,
- sharedWithMe: conversation.sharedWithMe,
- isPublished: conversation.isPublished,
- publishedWithMe: conversation.publishedWithMe,
- shareUniqueId: conversation.shareUniqueId,
- };
+ const cleanedConversation = cleanConversation(conversation);
- acc.push(cleanConversation);
+ acc.push(cleanedConversation);
return acc;
} catch (error) {
console.warn(
diff --git a/apps/chat/src/utils/app/common.ts b/apps/chat/src/utils/app/common.ts
new file mode 100644
index 0000000000..fe4e9445c1
--- /dev/null
+++ b/apps/chat/src/utils/app/common.ts
@@ -0,0 +1,54 @@
+import { getFoldersFromPaths } from '@/src/utils/app/folders';
+
+import { ConversationInfo } from '@/src/types/chat';
+import { Entity } from '@/src/types/common';
+import { FolderInterface, FolderType } from '@/src/types/folder';
+import { PromptInfo } from '@/src/types/prompt';
+
+/**
+ * Combine entities. If there are the same ids then will be used entity from entities1 i.e. first in array
+ * @param entities1
+ * @param entities2
+ * @returns new array without duplicates
+ */
+export const combineEntities = (
+ entities1: T[],
+ entities2: T[],
+): T[] => {
+ return entities1
+ .concat(entities2)
+ .filter(
+ (entity, index, self) =>
+ index === self.findIndex((c) => c.id === entity.id),
+ );
+};
+
+export const updateEntitiesFoldersAndIds = (
+ entities: PromptInfo[] | ConversationInfo[],
+ folders: FolderInterface[],
+ updateFolderId: (folderId: string | undefined) => string | undefined,
+ openedFoldersIds: string[],
+) => {
+ const allFolderIds = entities.map((prompt) => prompt.folderId as string);
+
+ const updatedExistedFolders = folders.map((f: FolderInterface) => ({
+ ...f,
+ id: updateFolderId(f.id)!,
+ folderId: updateFolderId(f.folderId),
+ }));
+
+ const newUniqueFolderIds = Array.from(new Set(allFolderIds)).map((id) =>
+ updateFolderId(id),
+ );
+
+ const updatedFolders = combineEntities(
+ getFoldersFromPaths(newUniqueFolderIds, FolderType.Chat),
+ updatedExistedFolders,
+ );
+
+ const updatedOpenedFoldersIds = openedFoldersIds.map(
+ (id) => updateFolderId(id)!,
+ );
+
+ return { updatedFolders, updatedOpenedFoldersIds };
+};
diff --git a/apps/chat/src/utils/app/conversation.ts b/apps/chat/src/utils/app/conversation.ts
index 59216e561b..2f449166c2 100644
--- a/apps/chat/src/utils/app/conversation.ts
+++ b/apps/chat/src/utils/app/conversation.ts
@@ -1,7 +1,17 @@
-import { Conversation, Message, MessageSettings } from '@/src/types/chat';
-import { EntityType } from '@/src/types/common';
+import {
+ Conversation,
+ ConversationInfo,
+ Message,
+ MessageSettings,
+ Role,
+} from '@/src/types/chat';
+import { EntityType, UploadStatus } from '@/src/types/common';
import { OpenAIEntityAddon, OpenAIEntityModel } from '@/src/types/openai';
+import { getConversationApiKey, parseConversationApiKey } from '../server/api';
+import { constructPath, notAllowedSymbolsRegex } from './file';
+import { compareEntitiesByName, splitPath } from './folders';
+
export const getAssitantModelId = (
modelType: EntityType,
defaultAssistantModelId: string,
@@ -15,7 +25,8 @@ export const getAssitantModelId = (
export const getValidEntitiesFromIds = (
entitiesIds: string[],
addonsMap: Partial>,
-) => entitiesIds.map((entityId) => addonsMap[entityId]).filter(Boolean) as T[];
+): T[] =>
+ entitiesIds.map((entityId) => addonsMap[entityId]).filter(Boolean) as T[];
export const getSelectedAddons = (
selectedAddons: string[],
@@ -35,7 +46,7 @@ export const getSelectedAddons = (
export const isSettingsChanged = (
conversation: Conversation,
newSettings: MessageSettings,
-) => {
+): boolean => {
const isChanged = Object.keys(newSettings).some((key) => {
const convSetting = conversation[key as keyof Conversation];
const newSetting = newSettings[key as keyof MessageSettings];
@@ -67,7 +78,7 @@ export const getNewConversationName = (
conversation: Conversation,
message: Message,
updatedMessages: Message[],
-) => {
+): string => {
if (
conversation.replay.isReplay ||
updatedMessages.length !== 2 ||
@@ -75,7 +86,7 @@ export const getNewConversationName = (
) {
return conversation.name;
}
- const content = message.content.trim();
+ const content = message.content.replaceAll(notAllowedSymbolsRegex, '').trim();
if (content.length > 0) {
return content.length > 160 ? content.substring(0, 160) + '...' : content;
} else if (message.custom_content?.attachments?.length) {
@@ -85,3 +96,92 @@ export const getNewConversationName = (
return conversation.name;
};
+
+export const getGeneratedConversationId = (
+ conversation: Omit,
+): string =>
+ constructPath(conversation.folderId, getConversationApiKey(conversation));
+
+export const addGeneratedConversationId = (
+ conversation: Omit,
+): T =>
+ ({
+ ...conversation,
+ id: getGeneratedConversationId(conversation),
+ }) as T;
+
+export const parseConversationId = (id: string): ConversationInfo => {
+ const { name, parentPath } = splitPath(id);
+ return addGeneratedConversationId({
+ ...parseConversationApiKey(name),
+ folderId: parentPath,
+ });
+};
+
+export const compareConversationsByDate = (
+ convA: ConversationInfo,
+ convB: ConversationInfo,
+): number => {
+ if (convA.lastActivityDate === convB.lastActivityDate) {
+ return compareEntitiesByName(convA, convB);
+ }
+ if (convA.lastActivityDate && convB.lastActivityDate) {
+ const dateA = convA.lastActivityDate;
+ const dateB = convB.lastActivityDate;
+ return dateB - dateA;
+ }
+ return -1;
+};
+
+const removePostfix = (name: string): string => {
+ const regex = / \d{1,3}$/;
+ let newName = name.trim();
+ while (regex.test(newName)) {
+ newName = newName.replace(regex, '').trim();
+ }
+ return newName;
+};
+
+export const isValidConversationForCompare = (
+ selectedConversation: Conversation,
+ candidate: ConversationInfo,
+): boolean => {
+ if (candidate.isReplay || candidate.isPlayback) {
+ return false;
+ }
+
+ if (candidate.id === selectedConversation.id) {
+ return false;
+ }
+ return (
+ removePostfix(selectedConversation.name) === removePostfix(candidate.name)
+ );
+};
+
+export const isChosenConversationValidForCompare = (
+ selectedConversation: Conversation,
+ chosenSelection: Conversation,
+): boolean => {
+ if (
+ chosenSelection.status !== UploadStatus.LOADED ||
+ chosenSelection.replay?.isReplay ||
+ chosenSelection.playback?.isPlayback
+ ) {
+ return false;
+ }
+ if (chosenSelection.id === selectedConversation.id) {
+ return false;
+ }
+ const convUserMessages = chosenSelection.messages.filter(
+ (message) => message.role === Role.User,
+ );
+ const selectedConvUserMessages = selectedConversation.messages.filter(
+ (message) => message.role === Role.User,
+ );
+
+ if (convUserMessages.length !== selectedConvUserMessages.length) {
+ return false;
+ }
+
+ return true;
+};
diff --git a/apps/chat/src/utils/app/data/bucket-service.ts b/apps/chat/src/utils/app/data/bucket-service.ts
new file mode 100644
index 0000000000..ff7b469d5f
--- /dev/null
+++ b/apps/chat/src/utils/app/data/bucket-service.ts
@@ -0,0 +1,23 @@
+import { Observable } from 'rxjs';
+
+import { ApiUtils } from '../../server/api';
+
+export class BucketService {
+ private static bucket: string;
+ public static requestBucket(): Observable<{ bucket: string }> {
+ return ApiUtils.request(`api/bucket`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ }
+
+ public static getBucket(): string {
+ return this.bucket;
+ }
+
+ public static setBucket(bucket: string): void {
+ this.bucket = bucket;
+ }
+}
diff --git a/apps/chat/src/utils/app/data/conversation-service.ts b/apps/chat/src/utils/app/data/conversation-service.ts
new file mode 100644
index 0000000000..da5e4afed2
--- /dev/null
+++ b/apps/chat/src/utils/app/data/conversation-service.ts
@@ -0,0 +1,76 @@
+import { Observable } from 'rxjs';
+
+import { Conversation, ConversationInfo } from '@/src/types/chat';
+import { FolderInterface, FoldersAndEntities } from '@/src/types/folder';
+import { UIStorageKeys } from '@/src/types/storage';
+
+import { DataService } from './data-service';
+import { BrowserStorage } from './storages/browser-storage';
+
+export class ConversationService {
+ public static getConversationsFolders(
+ path?: string,
+ ): Observable {
+ return DataService.getDataStorage().getConversationsFolders(path);
+ }
+
+ public static setConversationFolders(
+ folders: FolderInterface[],
+ ): Observable {
+ return DataService.getDataStorage().setConversationsFolders(folders);
+ }
+
+ public static createConversation(
+ conversation: Conversation,
+ ): Observable {
+ return DataService.getDataStorage().createConversation(conversation);
+ }
+
+ public static updateConversation(
+ conversation: Conversation,
+ ): Observable {
+ return DataService.getDataStorage().updateConversation(conversation);
+ }
+
+ public static deleteConversation(info: ConversationInfo): Observable {
+ return DataService.getDataStorage().deleteConversation(info);
+ }
+
+ public static getConversationsAndFolders(
+ path?: string,
+ ): Observable> {
+ return DataService.getDataStorage().getConversationsAndFolders(path);
+ }
+
+ public static getConversations(
+ path?: string,
+ recursive?: boolean,
+ ): Observable {
+ return DataService.getDataStorage().getConversations(path, recursive);
+ }
+
+ public static getConversation(
+ info: ConversationInfo,
+ ): Observable {
+ return DataService.getDataStorage().getConversation(info);
+ }
+
+ public static setConversations(
+ conversations: Conversation[],
+ ): Observable {
+ return DataService.getDataStorage().setConversations(conversations);
+ }
+
+ public static getSelectedConversationsIds(): Observable {
+ return BrowserStorage.getData(UIStorageKeys.SelectedConversationIds, []);
+ }
+
+ public static setSelectedConversationsIds(
+ selectedConversationsIds: string[],
+ ): Observable {
+ return BrowserStorage.setData(
+ UIStorageKeys.SelectedConversationIds,
+ selectedConversationsIds,
+ );
+ }
+}
diff --git a/apps/chat/src/utils/app/data/data-service.ts b/apps/chat/src/utils/app/data/data-service.ts
index 08f301972c..bfcc8b8ef4 100644
--- a/apps/chat/src/utils/app/data/data-service.ts
+++ b/apps/chat/src/utils/app/data/data-service.ts
@@ -3,23 +3,12 @@ import { Observable, map } from 'rxjs';
import { isSmallScreen } from '@/src/utils/app/mobile';
-import { Conversation } from '@/src/types/chat';
-import {
- BackendDataNodeType,
- BackendFile,
- BackendFileFolder,
- DialFile,
- FileFolderInterface,
-} from '@/src/types/files';
-import { FolderInterface, FolderType } from '@/src/types/folder';
-import { Prompt } from '@/src/types/prompt';
-import { DialStorage, UIStorageKeys } from '@/src/types/storage';
+import { DialStorage, StorageType, UIStorageKeys } from '@/src/types/storage';
import { Theme } from '@/src/types/themes';
import { SIDEBAR_MIN_WIDTH } from '@/src/constants/default-ui-settings';
-import { constructPath } from '../file';
-import { ApiMockStorage } from './storages/api-mock-storage';
+import { ApiUtils } from '../../server/api';
import { ApiStorage } from './storages/api-storage';
import { BrowserStorage } from './storages/browser-storage';
@@ -31,55 +20,25 @@ export class DataService {
this.setDataStorage(storageType);
}
- public static getConversationsFolders(): Observable {
- return this.getDataStorage().getConversationsFolders();
- }
-
- public static setConversationFolders(
- folders: FolderInterface[],
- ): Observable {
- return this.getDataStorage().setConversationsFolders(folders);
- }
-
- public static getPromptsFolders(): Observable {
- return this.getDataStorage().getPromptsFolders();
- }
-
- public static setPromptFolders(folders: FolderInterface[]): Observable {
- return this.getDataStorage().setPromptsFolders(folders);
- }
-
- public static getPrompts(): Observable {
- return this.getDataStorage().getPrompts();
- }
-
- public static setPrompts(prompts: Prompt[]): Observable {
- return this.getDataStorage().setPrompts(prompts);
- }
-
- public static getConversations(): Observable {
- return this.getDataStorage().getConversations();
- }
-
- public static setConversations(
- conversations: Conversation[],
- ): Observable {
- return this.getDataStorage().setConversations(conversations);
- }
-
- public static getSelectedConversationsIds(): Observable {
- return BrowserStorage.getData(UIStorageKeys.SelectedConversationIds, []);
+ public static getDataStorage(): DialStorage {
+ if (!this.dataStorage) {
+ this.setDataStorage();
+ }
+ return this.dataStorage;
}
- public static setSelectedConversationsIds(
- selectedConversationsIds: string[],
- ): Observable {
- return BrowserStorage.setData(
- UIStorageKeys.SelectedConversationIds,
- selectedConversationsIds,
- );
+ private static setDataStorage(dataStorageType?: string): void {
+ switch (dataStorageType) {
+ case StorageType.BrowserStorage:
+ this.dataStorage = new BrowserStorage();
+ break;
+ case StorageType.API:
+ default:
+ this.dataStorage = new ApiStorage();
+ }
}
+ // TODO: extract all this methods to separate services to prevent using Data service there
public static getRecentModelsIds(): Observable {
return BrowserStorage.getData(UIStorageKeys.RecentModelsIds, []);
}
@@ -117,7 +76,7 @@ export class DataService {
}
public static getAvailableThemes(): Observable {
- return ApiStorage.request('api/themes/listing');
+ return ApiUtils.request('api/themes/listing');
}
public static getChatbarWidth(): Observable {
@@ -184,166 +143,4 @@ export class DataService {
closedAnnouncementText || '',
);
}
-
- public static getFilesBucket(): Observable<{ bucket: string }> {
- return ApiStorage.request(`api/files/bucket`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
- }
-
- public static sendFile(
- formData: FormData,
- bucket: string,
- relativePath: string | undefined,
- fileName: string,
- ): Observable<{ percent?: number; result?: DialFile }> {
- const resultPath = encodeURI(
- `files/${bucket}/${relativePath ? `${relativePath}/` : ''}${fileName}`,
- );
-
- return ApiStorage.requestOld({
- url: `api/files/file/${resultPath}`,
- method: 'PUT',
- async: true,
- body: formData,
- }).pipe(
- map(
- ({
- percent,
- result,
- }: {
- percent?: number;
- result?: unknown;
- }): { percent?: number; result?: DialFile } => {
- if (percent) {
- return { percent };
- }
-
- if (!result) {
- return {};
- }
-
- const typedResult = result as BackendFile;
- const relativePath = typedResult.parentPath || undefined;
-
- return {
- result: {
- id: constructPath(relativePath, typedResult.name),
- name: typedResult.name,
- absolutePath: constructPath(
- 'files',
- typedResult.bucket,
- relativePath,
- ),
- relativePath: relativePath,
- folderId: relativePath,
- contentLength: typedResult.contentLength,
- contentType: typedResult.contentType,
- serverSynced: true,
- },
- };
- },
- ),
- );
- }
-
- public static getFileFolders(
- bucket: string,
- parentPath?: string,
- ): Observable {
- const filter = BackendDataNodeType.FOLDER;
-
- const query = new URLSearchParams({
- filter,
- bucket,
- ...(parentPath && { path: parentPath }),
- });
- const resultQuery = query.toString();
-
- return ApiStorage.request(`api/files/listing?${resultQuery}`).pipe(
- map((folders: BackendFileFolder[]) => {
- return folders.map((folder): FileFolderInterface => {
- const relativePath = folder.parentPath || undefined;
-
- return {
- id: constructPath(relativePath, folder.name),
- name: folder.name,
- type: FolderType.File,
- absolutePath: constructPath('files', bucket, relativePath),
- relativePath: relativePath,
- folderId: relativePath,
- serverSynced: true,
- };
- });
- }),
- );
- }
-
- public static removeFile(bucket: string, filePath: string): Observable {
- const resultPath = encodeURI(constructPath('files', bucket, filePath));
-
- return ApiStorage.request(`api/files/file/${resultPath}`, {
- method: 'DELETE',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
- }
-
- public static getFiles(
- bucket: string,
- parentPath?: string,
- ): Observable {
- const filter = BackendDataNodeType.ITEM;
-
- const query = new URLSearchParams({
- filter,
- bucket,
- ...(parentPath && { path: parentPath }),
- });
- const resultQuery = query.toString();
-
- return ApiStorage.request(`api/files/listing?${resultQuery}`).pipe(
- map((files: BackendFile[]) => {
- return files.map((file): DialFile => {
- const relativePath = file.parentPath || undefined;
-
- return {
- id: constructPath(relativePath, file.name),
- name: file.name,
- absolutePath: constructPath('files', file.bucket, relativePath),
- relativePath: relativePath,
- folderId: relativePath,
- contentLength: file.contentLength,
- contentType: file.contentType,
- serverSynced: true,
- };
- });
- }),
- );
- }
-
- private static getDataStorage(): DialStorage {
- if (!this.dataStorage) {
- this.setDataStorage();
- }
- return this.dataStorage;
- }
-
- private static setDataStorage(dataStorageType?: string): void {
- switch (dataStorageType) {
- case 'api':
- this.dataStorage = new ApiStorage();
- break;
- case 'apiMock':
- this.dataStorage = new ApiMockStorage();
- break;
- case 'browserStorage':
- default:
- this.dataStorage = new BrowserStorage();
- }
- }
}
diff --git a/apps/chat/src/utils/app/data/file-service.ts b/apps/chat/src/utils/app/data/file-service.ts
new file mode 100644
index 0000000000..8f300ce6f1
--- /dev/null
+++ b/apps/chat/src/utils/app/data/file-service.ts
@@ -0,0 +1,153 @@
+import { Observable, map } from 'rxjs';
+
+import { BackendDataNodeType } from '@/src/types/common';
+import {
+ BackendFile,
+ BackendFileFolder,
+ DialFile,
+ FileFolderInterface,
+} from '@/src/types/files';
+import { FolderType } from '@/src/types/folder';
+
+import { ApiKeys, ApiUtils } from '../../server/api';
+import { constructPath } from '../file';
+import { BucketService } from './bucket-service';
+
+export class FileService {
+ public static sendFile(
+ formData: FormData,
+ relativePath: string | undefined,
+ fileName: string,
+ ): Observable<{ percent?: number; result?: DialFile }> {
+ const resultPath = encodeURI(
+ constructPath(BucketService.getBucket(), relativePath, fileName),
+ );
+
+ return ApiUtils.requestOld({
+ url: `api/${ApiKeys.Files}/${resultPath}`,
+ method: 'PUT',
+ async: true,
+ body: formData,
+ }).pipe(
+ map(
+ ({
+ percent,
+ result,
+ }: {
+ percent?: number;
+ result?: unknown;
+ }): { percent?: number; result?: DialFile } => {
+ if (percent) {
+ return { percent };
+ }
+
+ if (!result) {
+ return {};
+ }
+
+ const typedResult = result as BackendFile;
+ const relativePath = typedResult.parentPath || undefined;
+
+ return {
+ result: {
+ id: constructPath(relativePath, typedResult.name),
+ name: typedResult.name,
+ absolutePath: constructPath(
+ ApiKeys.Files,
+ typedResult.bucket,
+ relativePath,
+ ),
+ relativePath: relativePath,
+ folderId: relativePath,
+ contentLength: typedResult.contentLength,
+ contentType: typedResult.contentType,
+ serverSynced: true,
+ },
+ };
+ },
+ ),
+ );
+ }
+
+ public static getFileFolders(
+ parentPath?: string,
+ ): Observable {
+ const filter = BackendDataNodeType.FOLDER;
+
+ const query = new URLSearchParams({
+ filter,
+ bucket: BucketService.getBucket(),
+ ...(parentPath && { path: parentPath }),
+ });
+ const resultQuery = query.toString();
+
+ return ApiUtils.request(`api/${ApiKeys.Files}/listing?${resultQuery}`).pipe(
+ map((folders: BackendFileFolder[]) => {
+ return folders.map((folder): FileFolderInterface => {
+ const relativePath = folder.parentPath || undefined;
+
+ return {
+ id: constructPath(relativePath, folder.name),
+ name: folder.name,
+ type: FolderType.File,
+ absolutePath: constructPath(
+ ApiKeys.Files,
+ BucketService.getBucket(),
+ relativePath,
+ ),
+ relativePath: relativePath,
+ folderId: relativePath,
+ serverSynced: true,
+ };
+ });
+ }),
+ );
+ }
+
+ public static removeFile(filePath: string): Observable {
+ const resultPath = encodeURI(
+ constructPath(BucketService.getBucket(), filePath),
+ );
+
+ return ApiUtils.request(`api/${ApiKeys.Files}/${resultPath}`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ }
+
+ public static getFiles(parentPath?: string): Observable {
+ const filter = BackendDataNodeType.ITEM;
+
+ const query = new URLSearchParams({
+ filter,
+ bucket: BucketService.getBucket(),
+ ...(parentPath && { path: parentPath }),
+ });
+ const resultQuery = query.toString();
+
+ return ApiUtils.request(`api/${ApiKeys.Files}/listing?${resultQuery}`).pipe(
+ map((files: BackendFile[]) => {
+ return files.map((file): DialFile => {
+ const relativePath = file.parentPath || undefined;
+
+ return {
+ id: constructPath(relativePath, file.name),
+ name: file.name,
+ absolutePath: constructPath(
+ ApiKeys.Files,
+ file.bucket,
+ relativePath,
+ ),
+ relativePath: relativePath,
+ folderId: relativePath,
+ contentLength: file.contentLength,
+ contentType: file.contentType,
+ serverSynced: true,
+ };
+ });
+ }),
+ );
+ }
+}
diff --git a/apps/chat/src/utils/app/data/prompt-service.ts b/apps/chat/src/utils/app/data/prompt-service.ts
new file mode 100644
index 0000000000..5b82368928
--- /dev/null
+++ b/apps/chat/src/utils/app/data/prompt-service.ts
@@ -0,0 +1,43 @@
+import { Observable } from 'rxjs';
+
+import { FolderInterface } from '@/src/types/folder';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
+
+import { DataService } from './data-service';
+
+export class PromptService {
+ public static getPromptsFolders(): Observable {
+ return DataService.getDataStorage().getPromptsFolders();
+ }
+
+ public static setPromptFolders(folders: FolderInterface[]): Observable {
+ return DataService.getDataStorage().setPromptsFolders(folders);
+ }
+
+ public static getPrompts(
+ path?: string,
+ recursive?: boolean,
+ ): Observable {
+ return DataService.getDataStorage().getPrompts(path, recursive);
+ }
+
+ public static getPrompt(info: PromptInfo): Observable {
+ return DataService.getDataStorage().getPrompt(info);
+ }
+
+ public static setPrompts(prompts: Prompt[]): Observable {
+ return DataService.getDataStorage().setPrompts(prompts);
+ }
+
+ public static createPrompt(prompt: Prompt): Observable {
+ return DataService.getDataStorage().createPrompt(prompt);
+ }
+
+ public static updatePrompt(prompt: Prompt): Observable {
+ return DataService.getDataStorage().updatePrompt(prompt);
+ }
+
+ public static deletePrompt(info: PromptInfo): Observable {
+ return DataService.getDataStorage().deletePrompt(info);
+ }
+}
diff --git a/apps/chat/src/utils/app/data/storage.ts b/apps/chat/src/utils/app/data/storage.ts
deleted file mode 100644
index 386c135e54..0000000000
--- a/apps/chat/src/utils/app/data/storage.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/* eslint-disable no-restricted-globals */
-import toast from 'react-hot-toast';
-
-import { errorsMessages } from '@/src/constants/errors';
-
-export const isLocalStorageEnabled = () => {
- const testData = 'test';
- try {
- localStorage.setItem(testData, testData);
- localStorage.removeItem(testData);
- return true;
- } catch (e) {
- if (e instanceof DOMException && e.name === 'QuotaExceededError') {
- toast.error(errorsMessages.localStorageQuotaExceeded);
- return true;
- } else {
- // eslint-disable-next-line no-console
- console.info(
- 'Local storage is unavailable and session storage is used for data instead',
- );
- return false;
- }
- }
-};
diff --git a/apps/chat/src/utils/app/data/storages/api-mock-storage.ts b/apps/chat/src/utils/app/data/storages/api-mock-storage.ts
deleted file mode 100644
index f08cb42472..0000000000
--- a/apps/chat/src/utils/app/data/storages/api-mock-storage.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Observable, of } from 'rxjs';
-
-import { Conversation } from '@/src/types/chat';
-import { EntityType } from '@/src/types/common';
-import { FolderInterface, FolderType } from '@/src/types/folder';
-import { Prompt } from '@/src/types/prompt';
-import { DialStorage } from '@/src/types/storage';
-
-export class ApiMockStorage implements DialStorage {
- setConversationsFolders(_folders: FolderInterface[]): Observable {
- return of(undefined);
- }
- setPromptsFolders(_folders: FolderInterface[]): Observable {
- return of(undefined);
- }
- setConversations(_conversations: Conversation[]): Observable {
- return of(undefined);
- }
- setPrompts(_prompts: Prompt[]): Observable {
- return of(undefined);
- }
- getConversations(): Observable {
- return of([
- {
- id: 'some conv ID',
- name: 'Mock conversation 1',
- messages: [],
- model: {
- id: 'modelId',
- maxLength: 1000,
- requestLimit: 1000,
- type: EntityType.Model,
- name: 'Some name',
- },
- isMessageStreaming: false,
- prompt: 'Some mock prompt',
- temperature: 1,
- replay: {
- isReplay: false,
- activeReplayIndex: 0,
- },
- selectedAddons: [],
- } as Conversation,
- ]);
- }
- getPrompts(): Observable {
- return of([
- {
- id: 'Some mock Prompt id',
- name: 'Mock Prompt 1',
- description: '',
- content: '',
- model: {
- id: 'modelId',
- maxLength: 1000,
- requestLimit: 1000,
- type: EntityType.Model,
- name: 'Some name',
- },
- },
- ]);
- }
- getPromptsFolders() {
- const folders: FolderInterface[] = [
- {
- id: 'Some prompt folder id 1',
- name: 'Folder name 1',
- type: FolderType.Chat,
- },
- {
- id: 'Some prompt folder id 2',
- name: 'Folder name 2',
- type: FolderType.Chat,
- },
- {
- id: 'Some prompt folder id 3',
- name: 'Folder name 3',
- type: FolderType.Chat,
- },
- ];
-
- return of(folders);
- }
- getConversationsFolders() {
- const folders: FolderInterface[] = [
- {
- id: 'Some chat folder id 1',
- name: 'Folder name 1',
- type: FolderType.Chat,
- },
- {
- id: 'Some chat folder id 2',
- name: 'Folder name 2',
- type: FolderType.Chat,
- },
- {
- id: 'Some chat folder id 3',
- name: 'Folder name 3',
- type: FolderType.Chat,
- },
- ];
-
- return of(folders);
- }
-}
diff --git a/apps/chat/src/utils/app/data/storages/api-storage.ts b/apps/chat/src/utils/app/data/storages/api-storage.ts
index 43bb59620d..a20da1243e 100644
--- a/apps/chat/src/utils/app/data/storages/api-storage.ts
+++ b/apps/chat/src/utils/app/data/storages/api-storage.ts
@@ -1,92 +1,96 @@
-import { Observable, from, switchMap, throwError } from 'rxjs';
-import { fromFetch } from 'rxjs/fetch';
+import { EMPTY, Observable, from, mergeMap } from 'rxjs';
-import { Conversation } from '@/src/types/chat';
-import { FolderInterface } from '@/src/types/folder';
-import { Prompt } from '@/src/types/prompt';
+import { Conversation, ConversationInfo } from '@/src/types/chat';
+import { Entity } from '@/src/types/common';
+import { FolderInterface, FoldersAndEntities } from '@/src/types/folder';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
import { DialStorage } from '@/src/types/storage';
+import { ConversationApiStorage } from './api/conversation-api-storage';
+import { PromptApiStorage } from './api/prompt-api-storage';
+
export class ApiStorage implements DialStorage {
- static request(url: string, options?: RequestInit) {
- return fromFetch(url, options).pipe(
- switchMap((response) => {
- if (!response.ok) {
- return throwError(() => new Error(response.statusText));
- }
-
- return from(response.json());
- }),
- );
- }
- static requestOld({
- url,
- method,
- async,
- body,
- }: {
- url: string | URL;
- method: string;
- async: boolean;
- body: XMLHttpRequestBodyInit | Document | null | undefined;
- }): Observable<{ percent?: number; result?: unknown }> {
- return new Observable((observer) => {
- const xhr = new XMLHttpRequest();
-
- xhr.open(method, url, async);
- xhr.responseType = 'json';
-
- // Track upload progress
- xhr.upload.onprogress = (event) => {
- if (event.lengthComputable) {
- const percentComplete = (event.loaded / event.total) * 100;
- observer.next({ percent: Math.round(percentComplete) });
- }
- };
-
- // Handle response
- xhr.onload = () => {
- if (xhr.status === 200) {
- observer.next({ result: xhr.response });
- observer.complete();
- } else {
- observer.error('Request failed');
- }
- };
-
- xhr.onerror = () => {
- observer.error('Request failed');
- };
-
- xhr.send(body);
-
- // Return cleanup function
- return () => {
- xhr.abort();
- };
- });
- }
- getConversationsFolders(): Observable {
- throw new Error('Method not implemented.');
+ private _conversationApiStorage = new ConversationApiStorage();
+ private _promptApiStorage = new PromptApiStorage();
+
+ getConversationsFolders(path?: string): Observable {
+ return this._conversationApiStorage.getFolders(path);
}
+
setConversationsFolders(_folders: FolderInterface[]): Observable {
- throw new Error('Method not implemented.');
+ return EMPTY; // don't need to save folders
}
- getPromptsFolders(): Observable {
- throw new Error('Method not implemented.');
+
+ getPromptsFolders(path?: string): Observable {
+ return this._promptApiStorage.getFolders(path);
}
+
setPromptsFolders(_folders: FolderInterface[]): Observable {
- throw new Error('Method not implemented.');
+ return EMPTY; // don't need to save folders
}
- getConversations(): Observable {
- throw new Error('Method not implemented.');
+
+ getConversationsAndFolders(
+ path?: string | undefined,
+ ): Observable> {
+ return this._conversationApiStorage.getFoldersAndEntities(path);
}
- setConversations(_conversations: Conversation[]): Observable {
- throw new Error('Method not implemented.');
+
+ getConversations(
+ path?: string,
+ recursive?: boolean,
+ ): Observable {
+ return this._conversationApiStorage.getEntities(path, recursive);
+ }
+
+ getConversation(info: ConversationInfo): Observable {
+ return this._conversationApiStorage.getEntity(info);
}
- getPrompts(): Observable {
- throw new Error('Method not implemented.');
+
+ createConversation(conversation: Conversation): Observable {
+ return this._conversationApiStorage.createEntity(conversation);
}
- setPrompts(_prompts: Prompt[]): Observable {
- throw new Error('Method not implemented.');
+ updateConversation(conversation: Conversation): Observable {
+ return this._conversationApiStorage.updateEntity(conversation);
+ }
+ deleteConversation(info: ConversationInfo): Observable {
+ return this._conversationApiStorage.deleteEntity(info);
+ }
+
+ setConversations(conversations: Conversation[]): Observable {
+ return from(conversations).pipe(
+ mergeMap((conversation) =>
+ this._conversationApiStorage.createEntity(conversation),
+ ),
+ );
+ }
+
+ getPromptsAndFolders(
+ path?: string | undefined,
+ ): Observable> {
+ return this._promptApiStorage.getFoldersAndEntities(path);
+ }
+
+ getPrompts(path?: string, recursive?: boolean): Observable {
+ return this._promptApiStorage.getEntities(path, recursive);
+ }
+
+ getPrompt(info: PromptInfo): Observable {
+ return this._promptApiStorage.getEntity(info);
+ }
+
+ createPrompt(prompt: Prompt): Observable {
+ return this._promptApiStorage.createEntity(prompt);
+ }
+ updatePrompt(prompt: Prompt): Observable {
+ return this._promptApiStorage.updateEntity(prompt);
+ }
+ deletePrompt(info: Entity): Observable {
+ return this._promptApiStorage.deleteEntity(info);
+ }
+
+ setPrompts(prompts: Prompt[]): Observable {
+ return from(prompts).pipe(
+ mergeMap((prompt) => this._promptApiStorage.createEntity(prompt)),
+ );
}
}
diff --git a/apps/chat/src/utils/app/data/storages/api/api-entity-storage.ts b/apps/chat/src/utils/app/data/storages/api/api-entity-storage.ts
new file mode 100644
index 0000000000..ea85f2fa9b
--- /dev/null
+++ b/apps/chat/src/utils/app/data/storages/api/api-entity-storage.ts
@@ -0,0 +1,186 @@
+import { EMPTY, Observable, catchError, map, of } from 'rxjs';
+
+import {
+ ApiKeys,
+ ApiUtils,
+ getFolderTypeByApiKey,
+} from '@/src/utils/server/api';
+
+import {
+ BackendChatEntity,
+ BackendChatFolder,
+ BackendDataNodeType,
+ UploadStatus,
+} from '@/src/types/common';
+import { FolderInterface, FoldersAndEntities } from '@/src/types/folder';
+import { EntityStorage } from '@/src/types/storage';
+
+import { constructPath } from '../../../file';
+import { BucketService } from '../../bucket-service';
+
+export abstract class ApiEntityStorage<
+ EntityInfo extends { folderId?: string },
+ Entity extends EntityInfo,
+> implements EntityStorage
+{
+ private mapFolder(folder: BackendChatFolder): FolderInterface {
+ const relativePath = folder.parentPath || undefined;
+
+ return {
+ id: constructPath(folder.parentPath, folder.name),
+ name: folder.name,
+ folderId: relativePath,
+ type: getFolderTypeByApiKey(this.getStorageKey()),
+ };
+ }
+
+ private mapEntity(entity: BackendChatEntity) {
+ const relativePath = entity.parentPath || undefined;
+ const info = this.parseEntityKey(entity.name);
+
+ return {
+ ...info,
+ id: constructPath(entity.parentPath, entity.name),
+ lastActivityDate: entity.updatedAt,
+ folderId: relativePath,
+ };
+ }
+
+ private getEntityUrl = (entity: EntityInfo): string =>
+ encodeURI(
+ constructPath(
+ 'api',
+ this.getStorageKey(),
+ BucketService.getBucket(),
+ entity.folderId,
+ this.getEntityKey(entity),
+ ),
+ );
+
+ private getListingUrl = (resultQuery: string): string => {
+ const listingUrl = encodeURI(
+ constructPath('api', this.getStorageKey(), 'listing'),
+ );
+ return `${listingUrl}?${resultQuery}`;
+ };
+
+ getFoldersAndEntities(
+ path?: string | undefined,
+ ): Observable> {
+ const query = new URLSearchParams({
+ bucket: BucketService.getBucket(),
+ ...(path && { path }),
+ });
+ const resultQuery = query.toString();
+
+ return ApiUtils.request(this.getListingUrl(resultQuery)).pipe(
+ map((items: (BackendChatFolder | BackendChatEntity)[]) => {
+ const folders = items.filter(
+ (item) => item.nodeType === BackendDataNodeType.FOLDER,
+ ) as BackendChatFolder[];
+ const entities = items.filter(
+ (item) => item.nodeType === BackendDataNodeType.ITEM,
+ ) as BackendChatEntity[];
+
+ return {
+ entities: entities.map((entity) => this.mapEntity(entity)),
+ folders: folders.map((folder) => this.mapFolder(folder)),
+ };
+ }),
+ catchError(() =>
+ of({
+ entities: [],
+ folders: [],
+ }),
+ ), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }
+
+ getFolders(path?: string | undefined): Observable {
+ const filter = BackendDataNodeType.FOLDER;
+
+ const query = new URLSearchParams({
+ filter,
+ bucket: BucketService.getBucket(),
+ ...(path && { path }),
+ });
+ const resultQuery = query.toString();
+
+ return ApiUtils.request(this.getListingUrl(resultQuery)).pipe(
+ map((folders: BackendChatFolder[]) => {
+ return folders.map((folder) => this.mapFolder(folder));
+ }),
+ catchError(() => of([])), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }
+
+ getEntities(path?: string, recursive?: boolean): Observable {
+ const filter = BackendDataNodeType.ITEM;
+
+ const query = new URLSearchParams({
+ filter,
+ bucket: BucketService.getBucket(),
+ ...(path && { path }),
+ ...(recursive && { recursive: String(recursive) }),
+ });
+ const resultQuery = query.toString();
+
+ return ApiUtils.request(this.getListingUrl(resultQuery)).pipe(
+ map((entities: BackendChatEntity[]) => {
+ return entities.map((entity) => this.mapEntity(entity));
+ }),
+ catchError(() => of([])), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }
+
+ getEntity(info: EntityInfo): Observable {
+ return ApiUtils.request(this.getEntityUrl(info)).pipe(
+ map((entity: Entity) => {
+ return {
+ ...this.mergeGetResult(info, entity),
+ status: UploadStatus.LOADED,
+ };
+ }),
+ catchError(() => of(null)), // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ );
+ }
+
+ createEntity(entity: Entity): Observable {
+ return ApiUtils.request(this.getEntityUrl(entity), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(this.cleanUpEntity(entity)),
+ }).pipe(catchError(() => EMPTY)); // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ }
+
+ updateEntity(entity: Entity): Observable {
+ return ApiUtils.request(this.getEntityUrl(entity), {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(this.cleanUpEntity(entity)),
+ }).pipe(catchError(() => EMPTY)); // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ }
+
+ deleteEntity(info: EntityInfo): Observable {
+ return ApiUtils.request(this.getEntityUrl(info), {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }).pipe(catchError(() => EMPTY)); // TODO: handle error it in https://github.com/epam/ai-dial-chat/issues/663
+ }
+
+ abstract getEntityKey(info: EntityInfo): string;
+
+ abstract parseEntityKey(key: string): EntityInfo;
+
+ abstract getStorageKey(): ApiKeys;
+
+ abstract cleanUpEntity(entity: Entity): Entity;
+
+ abstract mergeGetResult(info: EntityInfo, entity: Entity): Entity;
+}
diff --git a/apps/chat/src/utils/app/data/storages/api/conversation-api-storage.ts b/apps/chat/src/utils/app/data/storages/api/conversation-api-storage.ts
new file mode 100644
index 0000000000..faf966b5b5
--- /dev/null
+++ b/apps/chat/src/utils/app/data/storages/api/conversation-api-storage.ts
@@ -0,0 +1,36 @@
+import {
+ ApiKeys,
+ getConversationApiKey,
+ parseConversationApiKey,
+} from '@/src/utils/server/api';
+
+import { Conversation, ConversationInfo } from '@/src/types/chat';
+
+import { cleanConversation } from '../../../clean';
+import { ApiEntityStorage } from './api-entity-storage';
+
+export class ConversationApiStorage extends ApiEntityStorage<
+ ConversationInfo,
+ Conversation
+> {
+ mergeGetResult(info: ConversationInfo, entity: Conversation): Conversation {
+ return {
+ ...entity,
+ ...info,
+ lastActivityDate: info.lastActivityDate ?? entity.lastActivityDate,
+ model: entity.model,
+ };
+ }
+ cleanUpEntity(conversation: Conversation): Conversation {
+ return cleanConversation(conversation);
+ }
+ getEntityKey(info: ConversationInfo): string {
+ return getConversationApiKey(info);
+ }
+ parseEntityKey(key: string): ConversationInfo {
+ return parseConversationApiKey(key);
+ }
+ getStorageKey(): ApiKeys {
+ return ApiKeys.Conversations;
+ }
+}
diff --git a/apps/chat/src/utils/app/data/storages/api/prompt-api-storage.ts b/apps/chat/src/utils/app/data/storages/api/prompt-api-storage.ts
new file mode 100644
index 0000000000..13e9c839f2
--- /dev/null
+++ b/apps/chat/src/utils/app/data/storages/api/prompt-api-storage.ts
@@ -0,0 +1,34 @@
+import {
+ ApiKeys,
+ getPromptApiKey,
+ parsePromptApiKey,
+} from '@/src/utils/server/api';
+
+import { Entity } from '@/src/types/common';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
+
+import { ApiEntityStorage } from './api-entity-storage';
+
+export class PromptApiStorage extends ApiEntityStorage {
+ mergeGetResult(info: Entity, entity: Prompt): Prompt {
+ return {
+ ...entity,
+ ...info,
+ };
+ }
+ cleanUpEntity(entity: Prompt): Prompt {
+ return {
+ ...entity,
+ status: undefined,
+ };
+ }
+ getEntityKey(info: PromptInfo): string {
+ return getPromptApiKey(info);
+ }
+ parseEntityKey(key: string): PromptInfo {
+ return parsePromptApiKey(key);
+ }
+ getStorageKey(): ApiKeys {
+ return ApiKeys.Prompts;
+ }
+}
diff --git a/apps/chat/src/utils/app/data/storages/browser-storage.ts b/apps/chat/src/utils/app/data/storages/browser-storage.ts
index 27b116eae0..1b03f2c11f 100644
--- a/apps/chat/src/utils/app/data/storages/browser-storage.ts
+++ b/apps/chat/src/utils/app/data/storages/browser-storage.ts
@@ -1,17 +1,41 @@
/* eslint-disable no-restricted-globals */
import toast from 'react-hot-toast';
-import { Observable, map, of, switchMap, throwError } from 'rxjs';
+import { Observable, forkJoin, map, of, switchMap, throwError } from 'rxjs';
-import { Conversation } from '@/src/types/chat';
-import { FolderInterface, FolderType } from '@/src/types/folder';
-import { Prompt } from '@/src/types/prompt';
+import { Conversation, ConversationInfo } from '@/src/types/chat';
+import { Entity } from '@/src/types/common';
+import {
+ FolderInterface,
+ FolderType,
+ FoldersAndEntities,
+} from '@/src/types/folder';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
import { DialStorage, UIStorageKeys } from '@/src/types/storage';
import { errorsMessages } from '@/src/constants/errors';
import { cleanConversationHistory } from '../../clean';
-import { isLocalStorageEnabled } from '../storage';
+
+const isLocalStorageEnabled = () => {
+ const testData = 'test';
+ try {
+ localStorage.setItem(testData, testData);
+ localStorage.removeItem(testData);
+ return true;
+ } catch (e) {
+ if (e instanceof DOMException && e.name === 'QuotaExceededError') {
+ toast.error(errorsMessages.localStorageQuotaExceeded);
+ return true;
+ } else {
+ // eslint-disable-next-line no-console
+ console.info(
+ 'Local storage is unavailable and session storage is used for data instead',
+ );
+ return false;
+ }
+ }
+};
export class BrowserStorage implements DialStorage {
private static storage: globalThis.Storage | undefined;
@@ -24,12 +48,63 @@ export class BrowserStorage implements DialStorage {
}
}
+ getConversationsAndFolders(): Observable> {
+ return forkJoin({
+ folders: this.getConversationsFolders(),
+ entities: this.getConversations(),
+ });
+ }
+
getConversations(): Observable {
return BrowserStorage.getData(UIStorageKeys.ConversationHistory, []).pipe(
map((conversations) => cleanConversationHistory(conversations)),
);
}
+ getConversation(info: ConversationInfo): Observable {
+ return BrowserStorage.getData(UIStorageKeys.ConversationHistory, []).pipe(
+ map((conversations) => {
+ const conv = conversations.find(
+ (conv: Conversation) => conv.id === info.id,
+ );
+ return conv ? cleanConversationHistory([conv])[0] : null;
+ }),
+ );
+ }
+
+ createConversation(conversation: Conversation): Observable {
+ return BrowserStorage.getData(UIStorageKeys.ConversationHistory, []).pipe(
+ map((conversations: Conversation[]) => {
+ BrowserStorage.setData(UIStorageKeys.ConversationHistory, [
+ ...conversations,
+ conversation,
+ ]);
+ }),
+ );
+ }
+ updateConversation(conversation: Conversation): Observable {
+ return BrowserStorage.getData(UIStorageKeys.ConversationHistory, []).pipe(
+ map((conversations: Conversation[]) => {
+ BrowserStorage.setData(
+ UIStorageKeys.ConversationHistory,
+ conversations.map((conv) =>
+ conv.id === conversation.id ? conversation : conv,
+ ),
+ );
+ }),
+ );
+ }
+ deleteConversation(info: ConversationInfo): Observable {
+ return BrowserStorage.getData(UIStorageKeys.ConversationHistory, []).pipe(
+ map((conversations: Conversation[]) => {
+ BrowserStorage.setData(
+ UIStorageKeys.ConversationHistory,
+ conversations.filter((conv) => conv.id !== info.id),
+ );
+ }),
+ );
+ }
+
setConversations(conversations: Conversation[]): Observable {
return BrowserStorage.setData(
UIStorageKeys.ConversationHistory,
@@ -37,26 +112,76 @@ export class BrowserStorage implements DialStorage {
);
}
+ getPromptsAndFolders(): Observable> {
+ return forkJoin({
+ folders: this.getConversationsFolders(),
+ entities: this.getPrompts(),
+ });
+ }
+
getPrompts(): Observable {
return BrowserStorage.getData(UIStorageKeys.Prompts, []);
}
+ getPrompt(info: PromptInfo): Observable {
+ return BrowserStorage.getData(UIStorageKeys.Prompts, []).pipe(
+ map(
+ (prompts: Prompt[]) =>
+ prompts.find((prompt) => prompt.id === info.id) || null,
+ ),
+ );
+ }
+
+ createPrompt(prompt: Prompt): Observable {
+ return BrowserStorage.getData(UIStorageKeys.Prompts, []).pipe(
+ map((prompts: Prompt[]) => {
+ BrowserStorage.setData(UIStorageKeys.Prompts, [...prompts, prompt]);
+ }),
+ );
+ }
+ updatePrompt(prompt: Prompt): Observable {
+ return BrowserStorage.getData(UIStorageKeys.Prompts, []).pipe(
+ map((prompts: Prompt[]) => {
+ BrowserStorage.setData(
+ UIStorageKeys.Prompts,
+ prompts.map((item) => (prompt.id === item.id ? prompt : item)),
+ );
+ }),
+ );
+ }
+ deletePrompt(info: Entity): Observable {
+ return BrowserStorage.getData(UIStorageKeys.Prompts, []).pipe(
+ map((prompts: Prompt[]) => {
+ BrowserStorage.setData(
+ UIStorageKeys.Prompts,
+ prompts.filter((prompt) => prompt.id !== info.id),
+ );
+ }),
+ );
+ }
+
setPrompts(prompts: Prompt[]): Observable {
return BrowserStorage.setData(UIStorageKeys.Prompts, prompts);
}
- getConversationsFolders() {
+ getConversationsFolders(path?: string) {
return BrowserStorage.getData(UIStorageKeys.Folders, []).pipe(
map((folders: FolderInterface[]) => {
- return folders.filter((folder) => folder.type === FolderType.Chat);
+ return folders.filter(
+ (folder) =>
+ folder.type === FolderType.Chat && folder.folderId === path,
+ );
}),
);
}
- getPromptsFolders() {
+ getPromptsFolders(path?: string) {
return BrowserStorage.getData(UIStorageKeys.Folders, []).pipe(
map((folders: FolderInterface[]) => {
- return folders.filter((folder) => folder.type === FolderType.Prompt);
+ return folders.filter(
+ (folder) =>
+ folder.type === FolderType.Prompt && folder.folderId === path,
+ );
}),
);
}
@@ -76,6 +201,7 @@ export class BrowserStorage implements DialStorage {
),
);
}
+
setPromptsFolders(promptsFolders: FolderInterface[]): Observable {
return BrowserStorage.getData(UIStorageKeys.Folders, []).pipe(
map((items: FolderInterface[]) =>
diff --git a/apps/chat/src/utils/app/file.ts b/apps/chat/src/utils/app/file.ts
index 20f1e03c29..32a71e0166 100644
--- a/apps/chat/src/utils/app/file.ts
+++ b/apps/chat/src/utils/app/file.ts
@@ -1,9 +1,11 @@
import { Attachment, Conversation } from '@/src/types/chat';
+import { UploadStatus } from '@/src/types/common';
import { DialFile } from '@/src/types/files';
import { FolderInterface } from '@/src/types/folder';
import { getPathToFolderById } from './folders';
+import escapeStringRegexp from 'escape-string-regexp';
import { extensions } from 'mime-types';
export function triggerDownload(url: string, name: string): void {
@@ -45,7 +47,11 @@ export const getUserCustomContent = (
return {
attachments: files
- .filter((file) => file.status !== 'FAILED' && file.status !== 'UPLOADING')
+ .filter(
+ (file) =>
+ file.status !== UploadStatus.FAILED &&
+ file.status !== UploadStatus.LOADING,
+ )
.map((file) => ({
type: file.contentType,
title: file.name,
@@ -99,8 +105,11 @@ export const getFilesWithInvalidFileType = (
? []
: files.filter((file) => !isAllowedMimeType(allowedFileTypes, file.type));
};
-export const notAllowedSymbols = ':;,=/#\\\\';
-export const notAllowedSymbolsRegex = new RegExp(`[${notAllowedSymbols}]`, 'g');
+export const notAllowedSymbols = ':;,=/#?&';
+export const notAllowedSymbolsRegex = new RegExp(
+ `[${escapeStringRegexp(notAllowedSymbols)}]`,
+ 'g',
+);
export const getFilesWithInvalidFileName = (
files: T[],
): T[] => {
diff --git a/apps/chat/src/utils/app/folders.ts b/apps/chat/src/utils/app/folders.ts
index 266136556b..9e667bcf43 100644
--- a/apps/chat/src/utils/app/folders.ts
+++ b/apps/chat/src/utils/app/folders.ts
@@ -5,11 +5,11 @@ import {
notAllowedSymbolsRegex,
} from '@/src/utils/app/file';
-import { Conversation } from '@/src/types/chat';
-import { Entity, ShareEntity } from '@/src/types/common';
+import { Conversation, ConversationInfo } from '@/src/types/chat';
+import { ShareEntity, UploadStatus } from '@/src/types/common';
import { DialFile } from '@/src/types/files';
-import { FolderInterface } from '@/src/types/folder';
-import { Prompt } from '@/src/types/prompt';
+import { FolderInterface, FolderType } from '@/src/types/folder';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
import { EntityFilters } from '@/src/types/search';
import escapeStringRegexp from 'escape-string-regexp';
@@ -65,10 +65,6 @@ export const getChildAndCurrentFoldersById = (
folderId: string | undefined,
allFolders: FolderInterface[],
) => {
- if (!folderId) {
- return [];
- }
-
const currentFolder = allFolders.find((folder) => folder.id === folderId);
const childFolders = allFolders.filter(
(folder) => folder.folderId === folderId,
@@ -115,31 +111,46 @@ export const getNextDefaultName = (
index = 0,
startWithEmptyPostfix = false,
includingPublishedWithMe = false,
-) => {
+): string => {
const prefix = `${defaultName} `;
- const regex = new RegExp(`^${escapeStringRegexp(prefix)}(\\d{1,3})$`);
+ const regex = new RegExp(`^${escapeStringRegexp(prefix)}(\\d+)$`);
if (!entities.length) {
- return `${prefix}${1 + index}`;
+ return !startWithEmptyPostfix ? `${prefix}${1 + index}` : defaultName;
}
- const maxNumber = Math.max(
- ...entities
- .filter(
- (entity) =>
- !entity.sharedWithMe &&
- (!entity.publishedWithMe || includingPublishedWithMe) &&
- (entity.name === defaultName || entity.name.match(regex)),
- )
- .map((entity) => parseInt(entity.name.replace(prefix, ''), 10) || 1),
- 0,
- ); // max number
+ const maxNumber =
+ Math.max(
+ ...entities
+ .filter(
+ (entity) =>
+ !entity.sharedWithMe &&
+ (!entity.publishedWithMe || includingPublishedWithMe) &&
+ (entity.name === defaultName || entity.name.match(regex)),
+ )
+ .map(
+ (entity) =>
+ parseInt(entity.name.replace(prefix, ''), 10) ||
+ (startWithEmptyPostfix ? 0 : 1),
+ ),
+ startWithEmptyPostfix ? -1 : 0,
+ ) + index; // max number
+
+ if (maxNumber >= 9999999) {
+ return getNextDefaultName(
+ `${prefix}${maxNumber}`,
+ entities,
+ index,
+ startWithEmptyPostfix,
+ includingPublishedWithMe,
+ );
+ }
- if (startWithEmptyPostfix && maxNumber === 0) {
+ if (startWithEmptyPostfix && maxNumber === -1) {
return defaultName;
}
- return `${prefix}${maxNumber + 1 + index}`;
+ return `${prefix}${maxNumber + 1}`;
};
export const generateNextName = (
@@ -148,8 +159,7 @@ export const generateNextName = (
entities: ShareEntity[],
index = 0,
) => {
- const prefix = `${defaultName} `;
- const regex = new RegExp(`^${prefix}(\\d+)$`);
+ const regex = new RegExp(`^${defaultName} (\\d+)$`);
return currentName.match(regex)
? getNextDefaultName(defaultName, entities, index)
: getNextDefaultName(currentName, entities, index, true);
@@ -250,9 +260,12 @@ export const getFilteredFolders = ({
.flatMap((fid) => getParentAndCurrentFolderIdsById(folders, fid)),
);
- return folders.filter(
- (folder) => filteredIds.has(folder.id) && filteredFolderIds.has(folder.id),
- );
+ return folders
+ .filter(
+ (folder) =>
+ filteredIds.has(folder.id) && filteredFolderIds.has(folder.id),
+ )
+ .sort(compareEntitiesByName);
};
export const getParentAndChildFolders = (
@@ -343,7 +356,73 @@ export const getConversationAttachmentWithPath = (
).map((file) => ({ ...file, relativePath: path, contentLength: 0 }));
};
-export const compareEntitiesByName = (a: T, b: T) => {
+export const addGeneratedFolderId = (folder: Omit) => ({
+ ...folder,
+ id: constructPath(folder.folderId, folder.name),
+});
+
+export const splitPath = (id: string) => {
+ const parts = id.split('/');
+ const name = parts[parts.length - 1];
+ const parentPath =
+ parts.length > 1
+ ? constructPath(...parts.slice(0, parts.length - 1))
+ : undefined;
+ return {
+ name,
+ parentPath,
+ };
+};
+
+export const getAllPathsFromPath = (path?: string): string[] => {
+ if (!path) {
+ return [];
+ }
+ const parts = path.split('/');
+ const paths = [];
+ for (let i = 1; i <= parts.length; i++) {
+ const path = constructPath(...parts.slice(0, i));
+ paths.push(path);
+ }
+ return paths;
+};
+
+export const getAllPathsFromId = (id: string): string[] => {
+ const { parentPath } = splitPath(id);
+ return getAllPathsFromPath(parentPath);
+};
+
+export const getFolderFromPath = (
+ path: string,
+ type: FolderType,
+ status?: UploadStatus,
+): FolderInterface => {
+ const { name, parentPath } = splitPath(path);
+ return {
+ id: path,
+ name,
+ type,
+ folderId: parentPath,
+ status,
+ };
+};
+
+export const getFoldersFromPaths = (
+ paths: (string | undefined)[],
+ type: FolderType,
+ status?: UploadStatus,
+): FolderInterface[] => {
+ return (paths.filter(Boolean) as string[]).map((path) =>
+ getFolderFromPath(path, type, status),
+ );
+};
+
+export const compareEntitiesByName = <
+ T extends ConversationInfo | PromptInfo | DialFile,
+>(
+ a: T,
+ b: T,
+) => {
if (a.name > b.name) {
return 1;
}
@@ -352,3 +431,39 @@ export const compareEntitiesByName = (a: T, b: T) => {
}
return 0;
};
+
+export const updateMovedFolderId = (
+ oldParentFolderId: string | undefined,
+ newParentFolderId: string | undefined,
+ folderId: string | undefined,
+) => {
+ const curr = folderId || '';
+ const old = oldParentFolderId || '';
+ if (curr === old) {
+ return newParentFolderId;
+ }
+ const prefix = `${old}/`;
+ if (curr.startsWith(prefix)) {
+ if (!newParentFolderId) {
+ return curr.replace(prefix, '') || undefined;
+ }
+ return curr.replace(old, newParentFolderId);
+ }
+ return folderId;
+};
+
+export const updateMovedEntityId = (
+ oldParentFolderId: string | undefined,
+ newParentFolderId: string | undefined,
+ entityId: string,
+): string => {
+ const old = oldParentFolderId || '';
+ const prefix = `${old}/`;
+ if (entityId.startsWith(prefix)) {
+ if (!newParentFolderId) {
+ return entityId.replace(prefix, '');
+ }
+ return entityId.replace(old, newParentFolderId);
+ }
+ return entityId;
+};
diff --git a/apps/chat/src/utils/app/import-export.ts b/apps/chat/src/utils/app/import-export.ts
index 9eb35a3d30..7d3b41f755 100644
--- a/apps/chat/src/utils/app/import-export.ts
+++ b/apps/chat/src/utils/app/import-export.ts
@@ -1,4 +1,4 @@
-import { Attachment, Conversation } from '@/src/types/chat';
+import { Attachment, Conversation, ConversationInfo } from '@/src/types/chat';
import { DialFile } from '@/src/types/files';
import { FolderInterface, FolderType } from '@/src/types/folder';
import {
@@ -14,6 +14,7 @@ import {
import { Prompt } from '@/src/types/prompt';
import { cleanConversationHistory } from './clean';
+import { combineEntities } from './common';
import { triggerDownload } from './file';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -49,7 +50,7 @@ export function cleanData(data: SupportedExportFormats): CleanDataResponse {
if (isExportFormatV1(data)) {
const cleanHistoryData: LatestExportFormat = {
version: 4,
- history: cleanConversationHistory(data),
+ history: cleanConversationHistory(data as unknown as Conversation[]),
folders: [],
prompts: [],
};
@@ -208,7 +209,7 @@ export const exportPrompt = (prompt: Prompt, folders: FolderInterface[]) => {
};
export interface ImportConversationsResponse {
- history: Conversation[];
+ history: ConversationInfo[];
folders: FolderInterface[];
isError: boolean;
}
@@ -218,26 +219,21 @@ export const importConversations = (
currentConversations,
currentFolders,
}: {
- currentConversations: Conversation[];
+ currentConversations: ConversationInfo[];
currentFolders: FolderInterface[];
},
): ImportConversationsResponse => {
const { history, folders, isError } = cleanData(importedData);
- const newHistory: Conversation[] = [
- ...currentConversations,
- ...history,
- ].filter(
- (conversation, index, self) =>
- index === self.findIndex((c) => c.id === conversation.id),
+ const newHistory: ConversationInfo[] = combineEntities(
+ currentConversations,
+ history,
);
- const newFolders: FolderInterface[] = [...currentFolders, ...folders]
- .filter(
- (folder, index, self) =>
- index === self.findIndex((f) => f.id === folder.id),
- )
- .filter((folder) => folder.type === FolderType.Chat);
+ const newFolders: FolderInterface[] = combineEntities(
+ currentFolders,
+ folders,
+ ).filter((folder) => folder.type === FolderType.Chat);
return {
history: newHistory,
@@ -269,20 +265,15 @@ export const importPrompts = (
};
}
- const newPrompts: Prompt[] = currentPrompts
- .concat(importedData.prompts)
- .filter(
- (prompt, index, self) =>
- index === self.findIndex((p) => p.id === prompt.id),
- );
+ const newPrompts: Prompt[] = combineEntities(
+ currentPrompts,
+ importedData.prompts,
+ );
- const newFolders: FolderInterface[] = currentFolders
- .concat(importedData.folders)
- .filter(
- (folder, index, self) =>
- index === self.findIndex((p) => p.id === folder.id),
- )
- .filter((folder) => folder.type === 'prompt');
+ const newFolders: FolderInterface[] = combineEntities(
+ currentFolders,
+ importedData.folders,
+ ).filter((folder) => folder.type === FolderType.Prompt);
return { prompts: newPrompts, folders: newFolders, isError: false };
};
diff --git a/apps/chat/src/utils/app/move.ts b/apps/chat/src/utils/app/move.ts
index e185983e62..b965edd067 100644
--- a/apps/chat/src/utils/app/move.ts
+++ b/apps/chat/src/utils/app/move.ts
@@ -13,23 +13,25 @@ export enum MoveType {
FileFolder = 'files_folder',
}
-export const getFolderMoveType = (featureType?: FeatureType): MoveType => {
+export const getFolderMoveType = (featureType: FeatureType): MoveType => {
switch (featureType) {
case FeatureType.Chat:
return MoveType.ConversationFolder;
case FeatureType.Prompt:
return MoveType.PromptFolder;
+ case FeatureType.File:
default:
return MoveType.FileFolder;
}
};
-export const getEntityMoveType = (featureType?: FeatureType): MoveType => {
+export const getEntityMoveType = (featureType: FeatureType): MoveType => {
switch (featureType) {
case FeatureType.Chat:
return MoveType.Conversation;
case FeatureType.Prompt:
return MoveType.Prompt;
+ case FeatureType.File:
default:
return MoveType.File;
}
@@ -37,7 +39,7 @@ export const getEntityMoveType = (featureType?: FeatureType): MoveType => {
export const hasDragEventEntityData = (
event: DragEvent,
- featureType?: FeatureType,
+ featureType: FeatureType,
): boolean => {
return (
event.dataTransfer?.types.includes(getEntityMoveType(featureType)) ?? false
@@ -46,7 +48,7 @@ export const hasDragEventEntityData = (
export const hasDragEventFolderData = (
event: DragEvent,
- featureType?: FeatureType,
+ featureType: FeatureType,
): boolean => {
return (
event.dataTransfer?.types.includes(getFolderMoveType(featureType)) ?? false
@@ -55,7 +57,7 @@ export const hasDragEventFolderData = (
export const hasDragEventAnyData = (
event: DragEvent,
- featureType?: FeatureType,
+ featureType: FeatureType,
): boolean => {
return (
hasDragEventEntityData(event, featureType) ||
diff --git a/apps/chat/src/utils/app/prompts.ts b/apps/chat/src/utils/app/prompts.ts
new file mode 100644
index 0000000000..95c3c06598
--- /dev/null
+++ b/apps/chat/src/utils/app/prompts.ts
@@ -0,0 +1,9 @@
+import { Prompt } from '@/src/types/prompt';
+
+import { getPromptApiKey } from '../server/api';
+import { constructPath } from './file';
+
+export const addGeneratedPromptId = (prompt: Omit) => ({
+ ...prompt,
+ id: constructPath(prompt.folderId, getPromptApiKey(prompt)),
+});
diff --git a/apps/chat/src/utils/app/search.ts b/apps/chat/src/utils/app/search.ts
index 0659d7686b..aa39515f44 100644
--- a/apps/chat/src/utils/app/search.ts
+++ b/apps/chat/src/utils/app/search.ts
@@ -1,34 +1,18 @@
-import { Conversation } from '@/src/types/chat';
+import { Conversation, ConversationInfo } from '@/src/types/chat';
import { DialFile } from '@/src/types/files';
import { FolderInterface } from '@/src/types/folder';
import { OpenAIEntityAddon, OpenAIEntityModel } from '@/src/types/openai';
-import { Prompt } from '@/src/types/prompt';
+import { Prompt, PromptInfo } from '@/src/types/prompt';
import { EntityFilter, EntityFilters, SearchFilters } from '@/src/types/search';
import { ShareInterface } from '@/src/types/share';
import { getChildAndCurrentFoldersIdsById } from './folders';
-export const doesConversationContainSearchTerm = (
- conversation: Conversation,
+export const doesPromptOrConversationContainSearchTerm = (
+ conversation: ConversationInfo | PromptInfo,
searchTerm: string,
) => {
- return [
- conversation.name,
- ...conversation.messages.map((message) => message.content),
- ]
- .join(' ')
- .toLowerCase()
- .includes(searchTerm.toLowerCase());
-};
-
-export const doesPromptContainSearchTerm = (
- prompt: Prompt,
- searchTerm: string,
-) => {
- return [prompt.name, prompt.description, prompt.content]
- .join(' ')
- .toLowerCase()
- .includes(searchTerm.toLowerCase());
+ return conversation.name.toLowerCase().includes(searchTerm.toLowerCase());
};
export const doesFileContainSearchTerm = (
@@ -59,16 +43,15 @@ export const doesEntityContainSearchItem = <
if (!searchTerm) {
return true;
}
- if ('messages' in item) {
- // Conversation
- return doesConversationContainSearchTerm(item, searchTerm);
- } else if ('content' in item && 'description' in item) {
- // Prompt
- return doesPromptContainSearchTerm(item, searchTerm);
- } else if ('contentType' in item) {
+
+ if ('contentType' in item) {
// DialFile
return doesFileContainSearchTerm(item, searchTerm);
+ } else if ('name' in item) {
+ // Conversation or Prompt
+ return doesPromptOrConversationContainSearchTerm(item, searchTerm);
}
+
return false;
};
diff --git a/apps/chat/src/utils/app/share.ts b/apps/chat/src/utils/app/share.ts
index 7b0b85704e..fb7650b9b3 100644
--- a/apps/chat/src/utils/app/share.ts
+++ b/apps/chat/src/utils/app/share.ts
@@ -62,8 +62,8 @@ export const isEntityExternal = (entity: ShareEntity) =>
export const hasExternalParent = (
state: RootState,
- folderId?: string,
- featureType?: FeatureType,
+ folderId: string | undefined,
+ featureType: FeatureType,
) => {
if (!featureType || !folderId) return false;
@@ -75,7 +75,7 @@ export const hasExternalParent = (
export const isEntityOrParentsExternal = (
state: RootState,
entity: Entity,
- featureType?: FeatureType,
+ featureType: FeatureType,
) => {
return (
isEntityExternal(entity) ||
diff --git a/apps/chat/src/utils/server/__tests__/api.test.ts b/apps/chat/src/utils/server/__tests__/api.test.ts
new file mode 100644
index 0000000000..5b07895f7f
--- /dev/null
+++ b/apps/chat/src/utils/server/__tests__/api.test.ts
@@ -0,0 +1,13 @@
+import { decodeModelId, encodeModelId } from '../api';
+
+describe('decodeModelId and encodeModelId', () => {
+ it.each([
+ 'gpt_4',
+ 'gpt__4',
+ 'gpt%5F%5F4',
+ `gpt${encodeURI('%5F%5F')}4`,
+ 'gpt-4',
+ ])('decodeModelId(encodeModelId(%s))', (path: string) => {
+ expect(decodeModelId(encodeModelId(path))).toBe(path);
+ });
+});
diff --git a/apps/chat/src/utils/server/api.ts b/apps/chat/src/utils/server/api.ts
new file mode 100644
index 0000000000..583c7346df
--- /dev/null
+++ b/apps/chat/src/utils/server/api.ts
@@ -0,0 +1,193 @@
+import { NextApiRequest } from 'next';
+
+import { Observable, from, switchMap, throwError } from 'rxjs';
+import { fromFetch } from 'rxjs/fetch';
+
+import { Conversation, ConversationInfo } from '@/src/types/chat';
+import { FolderType } from '@/src/types/folder';
+import { PromptInfo } from '@/src/types/prompt';
+
+import { EMPTY_MODEL_ID } from '@/src/constants/default-settings';
+
+import { OpenAIError } from './error';
+
+export enum ApiKeys {
+ Files = 'files',
+ Conversations = 'conversations',
+ Prompts = 'prompts',
+}
+
+export const getFolderTypeByApiKey = (key: ApiKeys): FolderType => {
+ switch (key) {
+ case ApiKeys.Conversations:
+ return FolderType.Chat;
+ case ApiKeys.Prompts:
+ return FolderType.Prompt;
+ case ApiKeys.Files:
+ default:
+ return FolderType.File;
+ }
+};
+
+export const isValidEntityApiType = (apiKey: string): boolean => {
+ return Object.values(ApiKeys).includes(apiKey as ApiKeys);
+};
+
+export const getEntityTypeFromPath = (
+ req: NextApiRequest,
+): string | undefined => {
+ return Array.isArray(req.query.entitytype) ? '' : req.query.entitytype;
+};
+
+export const getEntityUrlFromSlugs = (
+ dialApiHost: string,
+ req: NextApiRequest,
+): string => {
+ const entityType = getEntityTypeFromPath(req);
+ const slugs = Array.isArray(req.query.slug)
+ ? req.query.slug
+ : [req.query.slug];
+
+ if (!slugs || slugs.length === 0) {
+ throw new OpenAIError(`No ${entityType} path provided`, '', '', '404');
+ }
+
+ return `${dialApiHost}/v1/${entityType}/${encodeURI(slugs.join('/'))}`;
+};
+
+const pathKeySeparator = '__';
+const encodedKeySeparator = '%5F%5F';
+
+export const combineApiKey = (...args: (string | number)[]): string =>
+ args.join(pathKeySeparator);
+
+export const encodeModelId = (modelId: string): string =>
+ modelId
+ .split(pathKeySeparator)
+ .map((i) => encodeURI(i))
+ .join(encodedKeySeparator);
+
+export const decodeModelId = (modelKey: string): string =>
+ modelKey
+ .split(encodedKeySeparator)
+ .map((i) => decodeURI(i))
+ .join(pathKeySeparator);
+
+enum PseudoModel {
+ Replay = 'replay',
+ Playback = 'playback',
+}
+
+const getModelApiIdFromConversation = (conversation: Conversation): string => {
+ if (conversation.replay?.isReplay ?? conversation.isReplay)
+ return PseudoModel.Replay;
+ if (conversation.playback?.isPlayback ?? conversation.isPlayback)
+ return PseudoModel.Playback;
+ return conversation.model.id;
+};
+
+// Format key: {modelId}__{name}
+export const getConversationApiKey = (
+ conversation: Omit,
+): string => {
+ if (conversation.model.id === EMPTY_MODEL_ID) {
+ return conversation.name;
+ }
+ return combineApiKey(
+ encodeModelId(getModelApiIdFromConversation(conversation as Conversation)),
+ conversation.name,
+ );
+};
+
+// Format key: {modelId}__{name}
+export const parseConversationApiKey = (apiKey: string): ConversationInfo => {
+ const parts = apiKey.split(pathKeySeparator);
+
+ const [modelId, name] =
+ parts.length < 2
+ ? [EMPTY_MODEL_ID, apiKey] // receive without postfix with model i.e. {name}
+ : [decodeModelId(parts[0]), parts.slice(1).join(pathKeySeparator)]; // receive correct format {modelId}__{name}
+
+ return {
+ id: name,
+ model: { id: modelId },
+ name,
+ isPlayback: modelId === PseudoModel.Playback,
+ isReplay: modelId === PseudoModel.Replay,
+ };
+};
+
+// Format key: {name:base64}
+export const getPromptApiKey = (prompt: Omit): string => {
+ return combineApiKey(prompt.name);
+};
+
+// Format key: {name}
+export const parsePromptApiKey = (name: string): PromptInfo => {
+ return {
+ id: name,
+ name,
+ };
+};
+
+export class ApiUtils {
+ static request(url: string, options?: RequestInit) {
+ return fromFetch(url, options).pipe(
+ switchMap((response) => {
+ if (!response.ok) {
+ return throwError(() => new Error(response.statusText));
+ }
+
+ return from(response.json());
+ }),
+ );
+ }
+
+ static requestOld({
+ url,
+ method,
+ async,
+ body,
+ }: {
+ url: string | URL;
+ method: string;
+ async: boolean;
+ body: XMLHttpRequestBodyInit | Document | null | undefined;
+ }): Observable<{ percent?: number; result?: unknown }> {
+ return new Observable((observer) => {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open(method, url, async);
+ xhr.responseType = 'json';
+
+ // Track upload progress
+ xhr.upload.onprogress = (event) => {
+ if (event.lengthComputable) {
+ const percentComplete = (event.loaded / event.total) * 100;
+ observer.next({ percent: Math.round(percentComplete) });
+ }
+ };
+
+ // Handle response
+ xhr.onload = () => {
+ if (xhr.status === 200) {
+ observer.next({ result: xhr.response });
+ observer.complete();
+ } else {
+ observer.error('Request failed');
+ }
+ };
+
+ xhr.onerror = () => {
+ observer.error('Request failed');
+ };
+
+ xhr.send(body);
+
+ // Return cleanup function
+ return () => {
+ xhr.abort();
+ };
+ });
+ }
+}
diff --git a/apps/chat/src/utils/server/error.ts b/apps/chat/src/utils/server/error.ts
new file mode 100644
index 0000000000..d0900451ff
--- /dev/null
+++ b/apps/chat/src/utils/server/error.ts
@@ -0,0 +1,13 @@
+export class OpenAIError extends Error {
+ type: string;
+ param: string;
+ code: string;
+
+ constructor(message: string, type: string, param: string, code: string) {
+ super(message);
+ this.name = 'OpenAIError';
+ this.type = type;
+ this.param = param;
+ this.code = code;
+ }
+}
diff --git a/apps/chat/src/utils/server/index.ts b/apps/chat/src/utils/server/index.ts
index ad09a16357..a0a6724527 100644
--- a/apps/chat/src/utils/server/index.ts
+++ b/apps/chat/src/utils/server/index.ts
@@ -13,6 +13,7 @@ import {
} from '../../constants/default-settings';
import { errorsMessages } from '@/src/constants/errors';
+import { OpenAIError } from './error';
import { getApiHeaders } from './get-headers';
import {
@@ -22,19 +23,7 @@ import {
} from 'eventsource-parser';
import fetch from 'node-fetch';
-export class OpenAIError extends Error {
- type: string;
- param: string;
- code: string;
-
- constructor(message: string, type: string, param: string, code: string) {
- super(message);
- this.name = 'OpenAIError';
- this.type = type;
- this.param = param;
- this.code = code;
- }
-}
+export { OpenAIError };
interface OpenAIErrorResponse extends Response {
error?: OpenAIError;
diff --git a/libs/shared/project.json b/libs/shared/project.json
index 0fb9b2369d..e582837dc2 100644
--- a/libs/shared/project.json
+++ b/libs/shared/project.json
@@ -43,6 +43,11 @@
"reportsDirectory": "../../coverage/libs/shared"
}
},
+ "test:watch": {
+ "options": {
+ "reportsDirectory": "../../coverage/libs/shared"
+ }
+ },
"test:coverage": {
"options": {
"reportsDirectory": "../../coverage/libs/shared"
diff --git a/nx.json b/nx.json
index 21ffc45181..cfffe038d0 100644
--- a/nx.json
+++ b/nx.json
@@ -15,6 +15,13 @@
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"]
},
+ "test:watch": {
+ "executor": "@nx/vite:test",
+ "outputs": ["{options.reportsDirectory}"],
+ "options": {
+ "watch": true
+ }
+ },
"test:coverage": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
diff --git a/package.json b/package.json
index 826449384f..5974770640 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"graph": "nx graph",
"build": "nx run-many -t build",
"test": "nx run-many -t test",
+ "test:watch": "nx run-many -t test:watch",
"test:coverage": "nx run-many -t test:coverage",
"publish": "nx run-many -t publish",
"publish:dry": "nx run-many -t publish --output-style=static --dry",
@@ -20,7 +21,8 @@
"affected:graph": "nx graph --affected",
"affected:build": "nx affected -t build",
"affected:test": "nx affected -t test",
- "affected:test:coverage": "nx affected -t test",
+ "affected:test:watch": "nx affected -t test:watch",
+ "affected:test:coverage": "nx affected -t test:coverage",
"affected:e2e": "nx affected -t e2e",
"affected:lint": "nx affected -t lint",
"affected:lint:fix": "nx affected -t lint:fix",