diff --git a/apps/chat-e2e/src/testData/expectedConstants.ts b/apps/chat-e2e/src/testData/expectedConstants.ts index 27fb72a3fd..f10b0b40a3 100644 --- a/apps/chat-e2e/src/testData/expectedConstants.ts +++ b/apps/chat-e2e/src/testData/expectedConstants.ts @@ -119,6 +119,8 @@ export const ExpectedConstants = { 'This link is temporary and will be active for 3 days. This conversation and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming or changing the model will stop sharing.', sharePromptText: 'This link is temporary and will be active for 3 days. This prompt and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming will stop sharing.', + shareApplicationText: + 'This application and its updates will be visible to users with the link. Renaming or changing the version will stop sharing.', shareConversationFolderText: 'This link is temporary and will be active for 3 days. This conversation folder and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming will stop sharing.', sharePromptFolderText: diff --git a/apps/chat/.env.development b/apps/chat/.env.development index 2bbc5dc70e..f5df3acc67 100644 --- a/apps/chat/.env.development +++ b/apps/chat/.env.development @@ -91,7 +91,7 @@ DIAL_ROLES_FIELD="dial_roles" # Application UI settings -ENABLED_FEATURES="conversations-section,prompts-section,top-settings,top-clear-conversation,top-chat-info,top-chat-model-settings,empty-chat-settings,header,footer,request-api-key,report-an-issue,likes,conversations-sharing,prompts-sharing,input-files,attachments-manager,conversations-publishing,prompts-publishing,custom-logo,input-links,custom-applications,message-templates,marketplace,quick-apps,code-apps" +ENABLED_FEATURES="conversations-section,prompts-section,top-settings,top-clear-conversation,top-chat-info,top-chat-model-settings,empty-chat-settings,header,footer,request-api-key,report-an-issue,likes,conversations-sharing,prompts-sharing,input-files,attachments-manager,conversations-publishing,prompts-publishing,custom-logo,input-links,custom-applications,message-templates,marketplace,quick-apps,code-apps,applications-sharing" NEXT_PUBLIC_APP_NAME="Local Development APP Name" NEXT_PUBLIC_DEFAULT_SYSTEM_PROMPT="" NEXT_PUBLIC_DEFAULT_TEMPERATURE="1" diff --git a/apps/chat/public/images/icons/unshare-user.svg b/apps/chat/public/images/icons/unshare-user.svg new file mode 100644 index 0000000000..67904362f9 --- /dev/null +++ b/apps/chat/public/images/icons/unshare-user.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/chat/public/locales/en/sidebar.json b/apps/chat/public/locales/en/sidebar.json index 02d05d0ce1..609a54a8aa 100644 --- a/apps/chat/public/locales/en/sidebar.json +++ b/apps/chat/public/locales/en/sidebar.json @@ -3,5 +3,6 @@ "share.modal.link_conversation": "This conversation and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming or changing the model will stop sharing.", "share.modal.link_prompt": "This prompt and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming will stop sharing.", "share.modal.link_conversations_folder": "This conversation folder and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming will stop sharing.", - "share.modal.link_prompts_folder": "This prompt folder and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming will stop sharing." + "share.modal.link_prompts_folder": "This prompt folder and future changes to it will be visible to users who follow the link. Only owner will be able to make changes. Renaming will stop sharing.", + "share.modal.link_application": "This application and its updates will be visible to users with the link. Renaming or changing the version will stop sharing." } diff --git a/apps/chat/src/components/Chat/ShareModal.tsx b/apps/chat/src/components/Chat/ShareModal.tsx index d521bb23d7..82f3b8194b 100644 --- a/apps/chat/src/components/Chat/ShareModal.tsx +++ b/apps/chat/src/components/Chat/ShareModal.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'next-i18next'; import { getShareType } from '@/src/utils/app/share'; +import { FeatureType } from '@/src/types/common'; import { ModalState } from '@/src/types/modal'; import { Translation } from '@/src/types/translation'; @@ -24,6 +25,8 @@ import { OUTSIDE_PRESS_AND_MOUSE_EVENT } from '@/src/constants/modal'; import Modal from '../Common/Modal'; import Tooltip from '../Common/Tooltip'; +import { SharePermission } from '@epam/ai-dial-shared'; + export const ShareModal = () => { const isShareModalClosed = useAppSelector( ShareSelectors.selectShareModalClosed, @@ -33,6 +36,37 @@ export const ShareModal = () => { } }; +interface ShareAccessOptionProps { + filterValue: string; + selected: boolean; + onSelect: (value: boolean) => void; +} + +const ShareAccessOption = ({ + filterValue, + selected, + onSelect, +}: ShareAccessOptionProps) => { + return ( + + ); +}; + export default function ShareModalView() { const { t } = useTranslation(Translation.SideBar); const dispatch = useAppDispatch(); @@ -42,11 +76,22 @@ export default function ShareModalView() { const [urlWasCopied, setUrlWasCopied] = useState(false); const timeoutRef = useRef>(); + const [editAccess, setEditAccess] = useState(false); const modalState = useAppSelector(ShareSelectors.selectShareModalState); - const invitationId = useAppSelector(ShareSelectors.selectInvitationId); + const readInvitationId = useAppSelector(ShareSelectors.selectInvitationId); + const writeInvitationId = useAppSelector( + ShareSelectors.selectWriteInvitationId, + ); + const invitationId = editAccess ? writeInvitationId : readInvitationId; + + const shareResourceId = useAppSelector(ShareSelectors.selectShareResourceId); + const shareResourceName = useAppSelector( ShareSelectors.selectShareResourceName, ); + const shareResourceVersion = useAppSelector( + ShareSelectors.selectShareResourceVersion, + ); const shareFeatureType = useAppSelector( ShareSelectors.selectShareFeatureType, ); @@ -57,6 +102,26 @@ export default function ShareModalView() { }, [shareFeatureType, isFolder]); const [url, setUrl] = useState(''); + const onChangeSharePermissionHandler = useCallback( + (isWrite: boolean) => { + setEditAccess(isWrite); + const shouldGetNewInvitationId = + (isWrite && !writeInvitationId) || (!isWrite && !readInvitationId); + + if (shareResourceId && shouldGetNewInvitationId) { + dispatch( + ShareActions.shareApplication({ + resourceId: shareResourceId, + permissions: isWrite + ? [SharePermission.READ, SharePermission.WRITE] + : [SharePermission.READ], + }), + ); + } + }, + [dispatch, readInvitationId, shareResourceId, writeInvitationId], + ); + useEffect(() => { setUrl(`${window?.location.origin}/share/${invitationId || ''}`); }, [invitationId]); @@ -86,7 +151,6 @@ export default function ShareModalView() { ); useEffect(() => () => clearTimeout(timeoutRef.current), []); - return (
+ {shareResourceVersion && Version: {shareResourceVersion}}

{t('share.modal.link.description')}

{t('share.modal.link', { context: sharingType })}

+ {shareFeatureType === FeatureType.Application && ( +
+ +
+ )}
+ SettingsSelectors.isFeatureEnabled(state, Feature.ApplicationsSharing), + ); + const versionsToSelect = useMemo(() => { return allModels.filter( (model) => @@ -136,9 +152,6 @@ export const TalkToCard = ({ isSelected, ]); - const isMyEntity = entity.id.startsWith( - getRootId({ featureType: FeatureType.Application }), - ); const isModifyDisabled = isApplicationStatusUpdating(entity); const playerStatus = getApplicationSimpleStatus(entity); @@ -170,6 +183,15 @@ export const TalkToCard = ({ [onSelectVersion], ); + const handleOpenSharing = useCallback(() => { + dispatch( + ShareActions.share({ + featureType: FeatureType.Application, + resourceId: entity.id, + }), + ); + }, [dispatch, entity.id]); + const isOldReplay = useMemo(() => { return ( entity.id === REPLAY_AS_IS_MODEL && @@ -208,13 +230,35 @@ export const TalkToCard = ({ { name: t('Edit'), dataQa: 'edit', - display: isMyEntity && !!onEdit, + display: (isMyEntity || !!canWrite) && !!onEdit, Icon: IconPencilMinus, onClick: (e: React.MouseEvent) => { e.stopPropagation(); onEdit(entity); }, }, + { + name: t('Share'), + dataQa: 'share', + display: isMyEntity && isApplicationsSharingEnabled, + Icon: IconUserShare, + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + handleOpenSharing(); + }, + }, + { + name: t('Unshare'), + dataQa: 'unshare', + display: + (!!entity.sharedWithMe || !!entity.isShared) && + isApplicationsSharingEnabled, + Icon: IconUserUnshare, + onClick: (e: React.MouseEvent) => { + setIsUnshareConfirmOpened(true); + e.stopPropagation(); + }, + }, { name: t('Publish'), dataQa: 'publish', @@ -229,7 +273,7 @@ export const TalkToCard = ({ name: t('Logs'), dataQa: 'app-logs', display: - isExecutable && playerStatus === SimpleApplicationStatus.UNDEPLOY, + !!isExecutable && playerStatus === SimpleApplicationStatus.UNDEPLOY, Icon: IconFileDescription, onClick: (e: React.MouseEvent) => { e.preventDefault(); @@ -259,11 +303,14 @@ export const TalkToCard = ({ isCodeAppsEnabled, PlayerIcon, onEdit, + canWrite, + isApplicationsSharingEnabled, onPublish, isExecutable, onDelete, isModifyDisabled, handleUpdateFunctionStatus, + handleOpenSharing, onOpenLogs, ], ); @@ -388,6 +435,9 @@ export const TalkToCard = ({ ))}
+ {isUnshareConfirmOpened && ( + + )} ); }; diff --git a/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizard.tsx b/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizard.tsx index 88c05f32f9..613737dff9 100644 --- a/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizard.tsx +++ b/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizard.tsx @@ -36,6 +36,7 @@ export const ApplicationWizard: React.FC = ({ const selectedApplication = useAppSelector( ApplicationSelectors.selectApplicationDetail, ); + const isSharedWithMe = selectedApplication?.sharedWithMe; const handleClose = useCallback(() => { onClose(false); @@ -81,6 +82,7 @@ export const ApplicationWizard: React.FC = ({ isEdit={isEdit} currentReference={currentReference} selectedApplication={isEdit ? selectedApplication : undefined} + isSharedWithMe={!!isSharedWithMe} /> )} diff --git a/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizardFooter.tsx b/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizardFooter.tsx index 4570e41e0d..e7130acfab 100644 --- a/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizardFooter.tsx +++ b/apps/chat/src/components/Common/ApplicationWizard/ApplicationWizardFooter.tsx @@ -92,10 +92,12 @@ export const ApplicationWizardFooter: FC = ({
- {isEdit && ( + {isEdit && !selectedApplication?.sharedWithMe && (
= ({ type, selectedApplication, currentReference, + isSharedWithMe, }) => { const { t } = useTranslation(Translation.Chat); @@ -146,6 +148,7 @@ export const CodeAppView: FC = ({ ...preparedData, reference: currentReference, id: selectedApplication.id, + sharedWithMe: isSharedWithMe, }; dispatch( @@ -171,6 +174,7 @@ export const CodeAppView: FC = ({ dispatch, isAppDeployed, isEdit, + isSharedWithMe, onClose, selectedApplication, t, @@ -227,9 +231,11 @@ export const CodeAppView: FC = ({ placeholder={t('Type name') || ''} id="name" error={errors.name?.message} - disabled={isAppDeployed} + disabled={isAppDeployed || isSharedWithMe} tooltip={ - (isAppDeployed && t('Undeploy application to edit name')) || '' + (isSharedWithMe && getSharedTooltip('name')) || + (isAppDeployed && t('Undeploy application to edit name')) || + '' } /> @@ -241,10 +247,12 @@ export const CodeAppView: FC = ({ error={errors.version?.message} control={control} name="version" - disabled={isAppDeployed} rules={validators['version']} + disabled={isAppDeployed || isSharedWithMe} tooltip={ - (isAppDeployed && t('Undeploy application to edit version')) || '' + (isSharedWithMe && getSharedTooltip('version')) || + (isAppDeployed && t('Undeploy application to edit version')) || + '' } /> @@ -262,6 +270,8 @@ export const CodeAppView: FC = ({ fileManagerModalTitle="Select application icon" allowedTypes={IMAGE_TYPES} error={errors.iconUrl?.message} + tooltip={isSharedWithMe ? getSharedTooltip('icon') : ''} + disabled={isSharedWithMe} /> )} /> diff --git a/apps/chat/src/components/Common/ApplicationWizard/CustomAppView.tsx b/apps/chat/src/components/Common/ApplicationWizard/CustomAppView.tsx index 477ea1601e..39a1e05434 100644 --- a/apps/chat/src/components/Common/ApplicationWizard/CustomAppView.tsx +++ b/apps/chat/src/components/Common/ApplicationWizard/CustomAppView.tsx @@ -3,7 +3,7 @@ import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'next-i18next'; -import { topicToOption } from '@/src/utils/app/application'; +import { getSharedTooltip, topicToOption } from '@/src/utils/app/application'; import { CustomApplicationModel } from '@/src/types/applications'; import { Translation } from '@/src/types/translation'; @@ -49,6 +49,7 @@ export const CustomAppView: React.FC = ({ type, currentReference, selectedApplication, + isSharedWithMe, }) => { const { t } = useTranslation(Translation.Chat); @@ -99,6 +100,7 @@ export const CustomAppView: React.FC = ({ ...preparedData, reference: currentReference, id: selectedApplication.id, + sharedWithMe: isSharedWithMe, }; dispatch( @@ -127,6 +129,8 @@ export const CustomAppView: React.FC = ({ placeholder={t('Type name') || ''} id="name" error={errors.name?.message} + disabled={isSharedWithMe} + tooltip={isSharedWithMe ? getSharedTooltip('name') : ''} /> = ({ control={control} name="version" rules={validators['version']} + disabled={isSharedWithMe} + tooltip={isSharedWithMe ? getSharedTooltip('version') : ''} /> = ({ fileManagerModalTitle="Select application icon" allowedTypes={IMAGE_TYPES} error={errors.iconUrl?.message} + disabled={isSharedWithMe} + tooltip={isSharedWithMe ? getSharedTooltip('icon') : ''} /> )} /> diff --git a/apps/chat/src/components/Common/ApplicationWizard/QuickAppView.tsx b/apps/chat/src/components/Common/ApplicationWizard/QuickAppView.tsx index f88947e5b5..d1ff9b67da 100644 --- a/apps/chat/src/components/Common/ApplicationWizard/QuickAppView.tsx +++ b/apps/chat/src/components/Common/ApplicationWizard/QuickAppView.tsx @@ -4,7 +4,7 @@ import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'next-i18next'; -import { topicToOption } from '@/src/utils/app/application'; +import { getSharedTooltip, topicToOption } from '@/src/utils/app/application'; import { CustomApplicationModel } from '@/src/types/applications'; import { Translation } from '@/src/types/translation'; @@ -49,6 +49,7 @@ export const QuickAppView: React.FC = ({ type, currentReference, selectedApplication, + isSharedWithMe, }) => { const { t } = useTranslation(Translation.Chat); @@ -98,6 +99,7 @@ export const QuickAppView: React.FC = ({ ...preparedData, reference: currentReference, id: selectedApplication.id, + sharedWithMe: isSharedWithMe, }; dispatch( @@ -126,6 +128,8 @@ export const QuickAppView: React.FC = ({ placeholder={t('Type name') || ''} id="name" error={errors.name?.message} + disabled={isSharedWithMe} + tooltip={isSharedWithMe ? getSharedTooltip('name') : ''} /> = ({ placeholder={DEFAULT_VERSION} id="version" error={errors.version?.message} - control={control} name="version" + control={control} rules={validators['version']} + disabled={isSharedWithMe} + tooltip={isSharedWithMe ? getSharedTooltip('version') : ''} /> = ({ fileManagerModalTitle="Select application icon" allowedTypes={IMAGE_TYPES} error={errors.iconUrl?.message} + disabled={isSharedWithMe} + tooltip={isSharedWithMe ? getSharedTooltip('icon') : ''} /> )} /> diff --git a/apps/chat/src/components/Common/ApplicationWizard/view-props.ts b/apps/chat/src/components/Common/ApplicationWizard/view-props.ts index 8dc090d103..c005c9c626 100644 --- a/apps/chat/src/components/Common/ApplicationWizard/view-props.ts +++ b/apps/chat/src/components/Common/ApplicationWizard/view-props.ts @@ -10,4 +10,5 @@ export interface ViewProps { isEdit?: boolean; currentReference?: string; selectedApplication?: CustomApplicationModel; + isSharedWithMe?: boolean; } diff --git a/apps/chat/src/components/Common/UnshareDialog.tsx b/apps/chat/src/components/Common/UnshareDialog.tsx new file mode 100644 index 0000000000..1a9c65e44c --- /dev/null +++ b/apps/chat/src/components/Common/UnshareDialog.tsx @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; + +import { useTranslation } from 'next-i18next'; + +import { FeatureType } from '@/src/types/common'; +import { DialAIEntityModel } from '@/src/types/models'; + +import { useAppDispatch } from '@/src/store/hooks'; +import { ShareActions } from '@/src/store/share/share.reducers'; + +import { ConfirmDialog } from './ConfirmDialog'; + +interface UnshareDialogProps { + entity: DialAIEntityModel; + setOpened: (state: boolean) => void; +} + +const UnshareDialog = ({ entity, setOpened }: UnshareDialogProps) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const handleConfirmUnshare = useCallback( + (confirmation: boolean) => { + if (!confirmation) { + setOpened(false); + return; + } + + if (entity.isShared) { + dispatch( + ShareActions.revokeAccess({ + resourceId: entity.id, + featureType: FeatureType.Application, + }), + ); + } + + if (entity.sharedWithMe) { + dispatch( + ShareActions.discardSharedWithMe({ + resourceIds: [entity.id], + featureType: FeatureType.Application, + }), + ); + } + + setOpened(false); + }, + [dispatch, entity.id, entity.isShared, entity.sharedWithMe, setOpened], + ); + + return ( + + ); +}; + +export default UnshareDialog; diff --git a/apps/chat/src/components/Marketplace/ApplicationCard.tsx b/apps/chat/src/components/Marketplace/ApplicationCard.tsx index 8728ed5b69..78e559c2a4 100644 --- a/apps/chat/src/components/Marketplace/ApplicationCard.tsx +++ b/apps/chat/src/components/Marketplace/ApplicationCard.tsx @@ -6,6 +6,7 @@ import { IconPlayerPlay, IconPlaystationSquare, IconTrashX, + IconUserShare, IconWorldShare, } from '@tabler/icons-react'; import React, { useCallback, useMemo, useState } from 'react'; @@ -24,6 +25,7 @@ import { import { getRootId } from '@/src/utils/app/id'; import { isMediumScreen } from '@/src/utils/app/mobile'; import { isEntityIdPublic } from '@/src/utils/app/publications'; +import { canWriteSharedWithMe } from '@/src/utils/app/share'; import { ApplicationStatus, @@ -39,6 +41,7 @@ import { AuthSelectors } from '@/src/store/auth/auth.reducers'; import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; import { ModelsSelectors } from '@/src/store/models/models.reducers'; import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; +import { ShareActions } from '@/src/store/share/share.reducers'; import { ModelIcon } from '@/src/components/Chatbar/ModelIcon'; import ContextMenu from '@/src/components/Common/ContextMenu'; @@ -47,10 +50,12 @@ import { ApplicationTopic } from '@/src/components/Marketplace/ApplicationTopic' import { FunctionStatusIndicator } from '@/src/components/Marketplace/FunctionStatusIndicator'; import Tooltip from '../Common/Tooltip'; +import UnshareDialog from '../Common/UnshareDialog'; import { ApplicationLogs } from './ApplicationLogs'; import LoaderIcon from '@/public/images/icons/loader.svg'; import UnpublishIcon from '@/public/images/icons/unpublish.svg'; +import IconUserUnshare from '@/public/images/icons/unshare-user.svg'; import { Feature, PublishActions } from '@epam/ai-dial-shared'; const DESKTOP_ICON_SIZE = 80; @@ -120,6 +125,7 @@ export const ApplicationCard = ({ const dispatch = useAppDispatch(); const [isOpenLogs, setIsOpenLogs] = useState(); + const [isUnshareConfirmOpened, setIsUnshareConfirmOpened] = useState(false); const installedModelIds = useAppSelector( ModelsSelectors.selectInstalledModelIds, @@ -132,9 +138,13 @@ export const ApplicationCard = ({ const isMyApp = entity.id.startsWith( getRootId({ featureType: FeatureType.Application }), ); + + const canWrite = canWriteSharedWithMe(entity); + const isModifyDisabled = isApplicationStatusUpdating(entity); const playerStatus = getApplicationSimpleStatus(entity); - const isExecutable = isExecutableApp(entity) && (isMyApp || isAdmin); + const isExecutable = + isExecutableApp(entity) && (isMyApp || isAdmin || canWrite); const PlayerIcon = useMemo(() => { switch (playerStatus) { @@ -162,6 +172,19 @@ export const ApplicationCard = ({ [setIsOpenLogs], ); + const handleOpenSharing = useCallback(() => { + dispatch( + ShareActions.share({ + featureType: FeatureType.Application, + resourceId: entity.id, + }), + ); + }, [dispatch, entity.id]); + + const isApplicationsSharingEnabled = useAppSelector((state) => + SettingsSelectors.isFeatureEnabled(state, Feature.ApplicationsSharing), + ); + const menuItems: DisplayMenuItemProps[] = useMemo( () => [ { @@ -186,13 +209,35 @@ export const ApplicationCard = ({ { name: t('Edit'), dataQa: 'edit', - display: isMyApp && !!onEdit, + display: (isMyApp || !!canWrite) && !!onEdit, Icon: IconPencilMinus, onClick: (e: React.MouseEvent) => { e.stopPropagation(); onEdit?.(entity); }, }, + { + name: t('Share'), + dataQa: 'share', + display: isMyApp && isApplicationsSharingEnabled, + Icon: IconUserShare, + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + handleOpenSharing(); + }, + }, + { + name: t('Unshare'), + dataQa: 'unshare', + display: + (!!entity.sharedWithMe || !!entity.isShared) && + isApplicationsSharingEnabled, + Icon: IconUserUnshare, + onClick: (e: React.MouseEvent) => { + setIsUnshareConfirmOpened(true); + e.stopPropagation(); + }, + }, { name: t('Publish'), dataQa: 'publish', @@ -217,7 +262,7 @@ export const ApplicationCard = ({ name: t('Logs'), dataQa: 'app-logs', display: - isExecutable && playerStatus === SimpleApplicationStatus.UNDEPLOY, + !!isExecutable && playerStatus === SimpleApplicationStatus.UNDEPLOY, Icon: IconFileDescription, onClick: (e: React.MouseEvent) => { e.preventDefault(); @@ -247,11 +292,14 @@ export const ApplicationCard = ({ isCodeAppsEnabled, PlayerIcon, onEdit, - isModifyDisabled, - onPublish, + canWrite, + isApplicationsSharingEnabled, isExecutable, onDelete, + isModifyDisabled, handleUpdateFunctionStatus, + handleOpenSharing, + onPublish, ], ); @@ -340,6 +388,9 @@ export const ApplicationCard = ({ entityId={entity.id} /> )} + {isUnshareConfirmOpened && ( + + )} ); }; diff --git a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx index 3f6a57ed68..05293bd154 100644 --- a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx +++ b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationFooter.tsx @@ -23,6 +23,7 @@ import { } from '@/src/utils/app/application'; import { getRootId, isApplicationId } from '@/src/utils/app/id'; import { isEntityIdPublic } from '@/src/utils/app/publications'; +import { canWriteSharedWithMe } from '@/src/utils/app/share'; import { ApplicationStatus, @@ -42,9 +43,11 @@ import Loader from '@/src/components/Common/Loader'; import { ModelVersionSelect } from '../../Chat/ModelVersionSelect'; import Tooltip from '../../Common/Tooltip'; +import UnshareDialog from '../../Common/UnshareDialog'; import { ApplicationLogs } from '../ApplicationLogs'; import UnpublishIcon from '@/public/images/icons/unpublish.svg'; +import IconUserUnshare from '@/public/images/icons/unshare-user.svg'; import { Feature, PublishActions } from '@epam/ai-dial-shared'; const getFunctionTooltip = (entity: DialAIEntityModel) => { @@ -100,6 +103,7 @@ export const ApplicationDetailsFooter = ({ const dispatch = useAppDispatch(); const [isOpenLogs, setIsOpenLogs] = useState(); + const [isUnshareConfirmOpened, setIsUnshareConfirmOpened] = useState(false); const isCodeAppsEnabled = useAppSelector((state) => SettingsSelectors.isFeatureEnabled(state, Feature.CodeApps), @@ -116,7 +120,11 @@ export const ApplicationDetailsFooter = ({ const Bookmark = installedModelIds.has(entity.reference) ? IconBookmarkFilled : IconBookmark; - const isExecutable = isExecutableApp(entity) && (isMyApp || isAdmin); + + const canWrite = canWriteSharedWithMe(entity); + + const isExecutable = + isExecutableApp(entity) && (isMyApp || isAdmin || canWrite); const isModifyDisabled = isApplicationStatusUpdating(entity); const playerStatus = getApplicationSimpleStatus(entity); const isAppInDeployment = isApplicationDeploymentInProgress(entity); @@ -155,6 +163,10 @@ export const ApplicationDetailsFooter = ({ ); }; + const isApplicationsSharingEnabled = useAppSelector((state) => + SettingsSelectors.isFeatureEnabled(state, Feature.ApplicationsSharing), + ); + return (
@@ -176,11 +188,22 @@ export const ApplicationDetailsFooter = ({ )} - + {(!!entity.sharedWithMe || !!entity.isShared) && + isApplicationsSharingEnabled && ( + + + + )} {isMyApp ? ( )} - {isMyApp && ( + {(isMyApp || canWrite) && (
); }; diff --git a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationHeader.tsx b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationHeader.tsx index 0c651d078f..a148e3f379 100644 --- a/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationHeader.tsx +++ b/apps/chat/src/components/Marketplace/ApplicationDetails/ApplicationHeader.tsx @@ -1,20 +1,52 @@ +import { IconUserShare } from '@tabler/icons-react'; +import { MouseEventHandler, useCallback } from 'react'; + +import { useTranslation } from 'next-i18next'; + import classNames from 'classnames'; +import { getRootId } from '@/src/utils/app/id'; + +import { FeatureType } from '@/src/types/common'; import { DialAIEntityModel } from '@/src/types/models'; +import { Translation } from '@/src/types/translation'; + +import { useAppDispatch, useAppSelector } from '@/src/store/hooks'; +import { SettingsSelectors } from '@/src/store/settings/settings.reducers'; +import { ShareActions } from '@/src/store/share/share.reducers'; import { FunctionStatusIndicator } from '@/src/components/Marketplace/FunctionStatusIndicator'; import { ModelIcon } from '../../Chatbar/ModelIcon'; import { ApplicationTopic } from '../ApplicationTopic'; +import { Feature } from '@epam/ai-dial-shared'; + interface Props { entity: DialAIEntityModel; isMobileView: boolean; } export const ApplicationDetailsHeader = ({ entity, isMobileView }: Props) => { - // const { t } = useTranslation(Translation.Marketplace); + const { t } = useTranslation(Translation.Marketplace); + const dispatch = useAppDispatch(); + const isMyApp = entity.id.startsWith( + getRootId({ featureType: FeatureType.Application }), + ); + const handleOpenSharing: MouseEventHandler = + useCallback(() => { + dispatch( + ShareActions.share({ + featureType: FeatureType.Application, + resourceId: entity.id, + }), + ); + }, [dispatch, entity.id]); + + const isApplicationsSharingEnabled = useAppSelector((state) => + SettingsSelectors.isFeatureEnabled(state, Feature.ApplicationsSharing), + ); // const dispatch = useAppDispatch(); // const contextMenuItems = useMemo( @@ -45,89 +77,100 @@ export const ApplicationDetailsHeader = ({ entity, isMobileView }: Props) => { // ); return ( -
- -
-
-
-
- {entity.topics?.map((topic) => ( - - ))} -
-
+
+
+ +
+
+
- {entity.name} + {entity.topics?.map((topic) => ( + + ))}
- -
-
- {/*
- - - {t('Share')} - - } - > -
-
- -
{entity.name}
+
+
+ {entity.name}
-
- {contextMenuItems.map(({ BrandIcon, text, ...props }) => ( - - - {text} - - } - className="flex w-full items-center gap-3 px-3 py-2 hover:bg-accent-primary-alpha" - {...props} + +
+
+ {/*
+ + + {t('Share')} + + } + > +
+
+ - ))} +
{entity.name}
+
+
+ {contextMenuItems.map(({ BrandIcon, text, ...props }) => ( + + + {text} + + } + className="flex w-full items-center gap-3 px-3 py-2 hover:bg-accent-primary-alpha" + {...props} + /> + ))} +
-
-
- -
*/} + + +
*/} +
+ {/*

+ {application.title} +

*/}
- {/*

- {application.title} -

*/}
+ {isMyApp && isApplicationsSharingEnabled && ( + + )}
); }; diff --git a/apps/chat/src/components/Settings/CustomLogoSelect.tsx b/apps/chat/src/components/Settings/CustomLogoSelect.tsx index 2cd630ee6c..486823e5fd 100644 --- a/apps/chat/src/components/Settings/CustomLogoSelect.tsx +++ b/apps/chat/src/components/Settings/CustomLogoSelect.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames'; import { Translation } from '@/src/types/translation'; +import Tooltip from '../Common/Tooltip'; import { FileManagerModal } from '../Files/FileManagerModal'; interface CustomLogoSelectProps { @@ -18,6 +19,8 @@ interface CustomLogoSelectProps { className?: string; fileManagerModalTitle?: string; allowedTypes?: string[]; + disabled?: boolean; + tooltip?: string; } export const CustomLogoSelect = ({ @@ -29,6 +32,8 @@ export const CustomLogoSelect = ({ className, fileManagerModalTitle, allowedTypes, + disabled, + tooltip, }: CustomLogoSelectProps) => { const [isSelectFilesDialogOpened, setIsSelectFilesDialogOpened] = useState(false); @@ -57,19 +62,26 @@ export const CustomLogoSelect = ({ > {localLogo ?? customPlaceholder ?? t('No custom logo')}
-
- - {localLogo && ( + +
- )} -
+ {localLogo && ( + + )} +
+
{isSelectFilesDialogOpened && ( {isProfileOpen && } + ); diff --git a/apps/chat/src/store/application/application.epics.ts b/apps/chat/src/store/application/application.epics.ts index 7a8971cf44..9e9fd249d2 100644 --- a/apps/chat/src/store/application/application.epics.ts +++ b/apps/chat/src/store/application/application.epics.ts @@ -32,7 +32,7 @@ import { DeleteType } from '@/src/constants/marketplace'; import { ApplicationActions } from '../application/application.reducers'; import { AuthSelectors } from '../auth/auth.reducers'; -import { ModelsActions } from '../models/models.reducers'; +import { ModelsActions, ModelsSelectors } from '../models/models.reducers'; const createApplicationEpic: AppEpic = (action$) => action$.pipe( @@ -111,9 +111,14 @@ const updateApplicationEpic: AppEpic = (action$) => action$.pipe( filter(ApplicationActions.update.match), switchMap(({ payload }) => { + if (payload.applicationData.sharedWithMe) { + return of(ApplicationActions.edit(payload.applicationData)); + } + const updatedCustomApplication = regenerateApplicationId( payload.applicationData, ) as CustomApplicationModel; + if (payload.oldApplicationId !== updatedCustomApplication.id) { return DataService.getDataStorage() .move({ @@ -169,14 +174,20 @@ const editApplicationEpic: AppEpic = (action$) => }), ); -const getApplicationEpic: AppEpic = (action$) => +const getApplicationEpic: AppEpic = (action$, state$) => action$.pipe( filter(ApplicationActions.get.match), switchMap(({ payload }) => ApplicationService.get(payload).pipe( map((application) => { + const modelsMap = ModelsSelectors.selectModelsMap(state$.value); return application - ? ApplicationActions.getSuccess(application) + ? ApplicationActions.getSuccess({ + ...application, + sharedWithMe: modelsMap[application.reference]?.sharedWithMe, + permissions: modelsMap[application.reference]?.permissions, + isShared: modelsMap[application.reference]?.isShared, + }) : ApplicationActions.getFail(); }), catchError((err) => { diff --git a/apps/chat/src/store/models/models.reducers.ts b/apps/chat/src/store/models/models.reducers.ts index 64408fbd6b..1c37230514 100644 --- a/apps/chat/src/store/models/models.reducers.ts +++ b/apps/chat/src/store/models/models.reducers.ts @@ -1,9 +1,10 @@ import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit'; import { combineEntities } from '@/src/utils/app/common'; +import { canWriteSharedWithMe } from '@/src/utils/app/share'; import { translate } from '@/src/utils/app/translation'; -import { ApplicationStatus } from '@/src/types/applications'; +import { ApplicationInfo, ApplicationStatus } from '@/src/types/applications'; import { EntityType } from '@/src/types/common'; import { ErrorMessage } from '@/src/types/error'; import { @@ -219,25 +220,37 @@ export const modelsSlice = createSlice({ oldApplicationId: string; }>, ) => { + const oldModel = state.modelsMap[payload.model.reference]; + //Copy permissions and sharedWithMe after update + const newModel: DialAIEntityModel = { + sharedWithMe: oldModel?.sharedWithMe, + permissions: oldModel?.permissions, + ...payload.model, + }; + state.models = state.models.map((model) => - model.reference === payload.model.reference ? payload.model : model, + model.reference === newModel.reference ? newModel : model, ); state.modelsMap = omit(state.modelsMap, [payload.oldApplicationId]); - state.modelsMap[payload.model.id] = payload.model; - state.modelsMap[payload.model.reference] = payload.model; + state.modelsMap[newModel.id] = newModel; + state.modelsMap[newModel.reference] = newModel; }, deleteModels: ( state, { payload }: PayloadAction<{ references: string[] }>, ) => { + const ids = payload.references + .map((reference) => state.modelsMap[reference]?.id) + .filter(Boolean) as string[]; state.models = state.models.filter( (model) => !payload.references.includes(model.reference), ); state.recentModelsIds = state.recentModelsIds.filter( (id) => !payload.references.includes(id), ); - state.modelsMap = omit(state.modelsMap, payload.references); + state.modelsMap = omit(state.modelsMap, [...payload.references, ...ids]); }, + addPublishRequestModels: ( state, { @@ -273,6 +286,37 @@ export const modelsSlice = createSlice({ state.modelsMap[targetModel.reference] = updatedModel; } }, + updateLocalModels: ( + state, + { + payload, + }: PayloadAction<{ + reference: string; + updatedValues: Partial; + }>, + ) => { + const model = state.modelsMap[payload.reference]; + + if (model) { + const updatedModel = { + ...model, + ...payload.updatedValues, + }; + state.modelsMap[model.reference] = updatedModel; + state.modelsMap[model.id] = updatedModel; + + state.models = state.models.map((model) => { + if (model.reference === payload.reference) { + return { + ...model, + ...payload.updatedValues, + }; + } + + return model; + }); + } + }, }, }); @@ -377,6 +421,24 @@ const selectInitialized = createSelector( (state) => state.initialized, ); +const selectCustomModels = createSelector([rootSelector], (state) => { + return state.models.filter((model) => model.reference !== model.id); +}); + +const selectSharedWithMeModels = createSelector( + [selectCustomModels], + (customModels) => { + return customModels.filter((model) => model.sharedWithMe); + }, +); + +const selectSharedWriteModels = createSelector( + [selectCustomModels], + (customModels) => { + return customModels.filter((model) => canWriteSharedWithMe(model)); + }, +); + export const ModelsSelectors = { selectIsInstalledModelsInitialized, selectIsModelsLoaded, @@ -384,6 +446,7 @@ export const ModelsSelectors = { selectModelsError, selectModels, selectModelsMap, + selectCustomModels, selectInstalledModels, selectInstalledModelIds, selectRecentModelsIds, @@ -395,6 +458,8 @@ export const ModelsSelectors = { selectModelTopics, selectRecentWithInstalledModelsIds, selectInitialized, + selectSharedWithMeModels, + selectSharedWriteModels, }; export const ModelsActions = modelsSlice.actions; diff --git a/apps/chat/src/store/settings/settings.reducers.ts b/apps/chat/src/store/settings/settings.reducers.ts index 251ec16169..0769e1e517 100644 --- a/apps/chat/src/store/settings/settings.reducers.ts +++ b/apps/chat/src/store/settings/settings.reducers.ts @@ -218,6 +218,8 @@ const isSharingEnabled = (state: RootState, featureType: FeatureType) => { return enabledFeatures.has(Feature.ConversationsSharing); case FeatureType.Prompt: return enabledFeatures.has(Feature.PromptsSharing); + case FeatureType.Application: + return enabledFeatures.has(Feature.ApplicationsSharing); default: return false; diff --git a/apps/chat/src/store/share/share.epics.ts b/apps/chat/src/store/share/share.epics.ts index 04015d878b..7a02d9e86c 100644 --- a/apps/chat/src/store/share/share.epics.ts +++ b/apps/chat/src/store/share/share.epics.ts @@ -4,6 +4,7 @@ import { catchError, concat, filter, + iif, map, mergeMap, of, @@ -23,8 +24,14 @@ import { isConversationHasExternalAttachments, } from '@/src/utils/app/file'; import { splitEntityId } from '@/src/utils/app/folders'; -import { isConversationId, isFolderId, isPromptId } from '@/src/utils/app/id'; +import { + isApplicationId, + isConversationId, + isFolderId, + isPromptId, +} from '@/src/utils/app/id'; import { EnumMapper } from '@/src/utils/app/mappers'; +import { hasWritePermission } from '@/src/utils/app/share'; import { translate } from '@/src/utils/app/translation'; import { ApiUtils, parseConversationApiKey } from '@/src/utils/server/api'; @@ -37,17 +44,22 @@ import { ShareByLinkResponseModel, ShareRelations, ShareRequestType, + ShareResource, } from '@/src/types/share'; import { AppEpic } from '@/src/types/store'; import { DEFAULT_CONVERSATION_NAME } from '@/src/constants/default-ui-settings'; import { errorsMessages } from '@/src/constants/errors'; +import { DeleteType } from '@/src/constants/marketplace'; +import { ApplicationSelectors } from '../application/application.reducers'; import { ConversationsActions, ConversationsSelectors, } from '../conversations/conversations.reducers'; import { FilesActions, FilesSelectors } from '../files/files.reducers'; +import { MarketplaceActions } from '../marketplace/marketplace.reducers'; +import { ModelsActions, ModelsSelectors } from '../models/models.reducers'; import { PromptsActions, PromptsSelectors } from '../prompts/prompts.reducers'; import { SettingsSelectors } from '../settings/settings.reducers'; import { UIActions } from '../ui/ui.reducers'; @@ -85,7 +97,7 @@ const shareEpic: AppEpic = (action$) => }), ); } - } else { + } else if (payload.featureType === FeatureType.Prompt) { if (!payload.isFolder) { return of( ShareActions.sharePrompt({ resourceId: payload.resourceId }), @@ -97,6 +109,13 @@ const shareEpic: AppEpic = (action$) => }), ); } + } else { + return of( + ShareActions.shareApplication({ + resourceId: payload.resourceId, + permissions: payload.permissions, + }), + ); } }), ); @@ -270,6 +289,57 @@ const sharePromptFolderEpic: AppEpic = (action$) => }), ); +const shareApplicationEpic: AppEpic = (action$, state$) => + action$.pipe( + filter(ShareActions.shareApplication.match), + switchMap(({ payload }) => { + const resources: ShareResource[] = [ + { + url: ApiUtils.encodeApiUrl(payload.resourceId), + permissions: payload.permissions, + }, + ]; + + const applicationDetails = ApplicationSelectors.selectApplicationDetail( + state$.value, + ); + + if (applicationDetails?.iconUrl) { + resources.push({ + url: ApiUtils.encodeApiUrl(applicationDetails.iconUrl), + }); + } + + if ( + hasWritePermission(payload.permissions) && + applicationDetails?.function?.sourceFolder + ) { + resources.push({ + url: + ApiUtils.encodeApiUrl(applicationDetails?.function?.sourceFolder) + + '/', + permissions: payload.permissions, + }); + } + + return ShareService.share({ + invitationType: ShareRequestType.link, + resources, + }).pipe( + map((response: ShareByLinkResponseModel) => { + return ShareActions.shareSuccess({ + invitationId: response.invitationLink.split('/').slice(-1)?.[0], + permissions: payload.permissions, + }); + }), + catchError((err) => { + console.error(err); + return of(ShareActions.shareFail()); + }), + ); + }), + ); + const shareFailEpic: AppEpic = (action$) => action$.pipe( filter(ShareActions.shareFail.match), @@ -294,15 +364,20 @@ const acceptInvitationEpic: AppEpic = (action$) => switchMap((data) => { const acceptedIds = data.resources.filter( (resource) => - isPromptId(resource.url) || isConversationId(resource.url), + isPromptId(resource.url) || + isConversationId(resource.url) || + isApplicationId(resource.url), ); + const acceptedId = ApiUtils.decodeApiUrl(acceptedIds[0].url); + return of( ShareActions.acceptShareInvitationSuccess({ - acceptedId: ApiUtils.decodeApiUrl(acceptedIds[0].url), + acceptedId, isFolder: isFolderId(data.resources[0].url), isConversation: isConversationId(data.resources[0].url), isPrompt: isPromptId(data.resources[0].url), + isApplication: isApplicationId(acceptedId), }), ); }), @@ -324,7 +399,14 @@ const acceptInvitationSuccessEpic: AppEpic = (action$) => action$.pipe( filter(ShareActions.acceptShareInvitationSuccess.match), switchMap(({ payload }) => { - history.replaceState({}, '', window.location.origin); + if (payload.isApplication) { + window.location.replace('/marketplace'); + + //TODO make request for the shared applications to add them into the state when share invitation is accepted. + return of(ModelsActions.getModels()); + } else { + history.replaceState({}, '', window.location.origin); + } if (payload.isPrompt) { return of(UIActions.setShowPromptbar(true)); @@ -446,6 +528,40 @@ const triggerGettingSharedListingsAttachmentsEpic: AppEpic = ( }), ); +const triggerGettingSharedListingsApplicationsEpic: AppEpic = ( + action$, + state$, +) => + action$.pipe( + filter( + (action) => + ModelsActions.getModelsSuccess.match(action) || + ShareActions.triggerGettingSharedApplicationsListings.match(action), + ), + filter(() => { + return SettingsSelectors.isSharingEnabled( + state$.value, + FeatureType.Application, + ); + }), + switchMap(() => { + return concat( + of( + ShareActions.getSharedListing({ + featureType: FeatureType.Application, + sharedWith: ShareRelations.me, + }), + ), + of( + ShareActions.getSharedListing({ + featureType: FeatureType.Application, + sharedWith: ShareRelations.others, + }), + ), + ); + }), + ); + const getSharedListingEpic: AppEpic = (action$) => action$.pipe( filter(ShareActions.getSharedListing.match), @@ -761,6 +877,56 @@ const getSharedListingSuccessEpic: AppEpic = (action$, state$) => } } + if (payload.featureType === FeatureType.Application) { + const modelsMap = ModelsSelectors.selectModelsMap(state$.value); + if (payload.sharedWith === ShareRelations.others) { + actions.push( + ...(payload.resources.entities + .map((sharedItem) => { + const sharedModel = modelsMap[sharedItem.id]; + + if (sharedModel) { + return ModelsActions.updateLocalModels({ + reference: sharedModel.reference, + updatedValues: { + isShared: true, + }, + }); + } + return undefined; + }) + .filter(Boolean) as AnyAction[]), + ); + } else { + //TODO make request for the shared applications to add them into the state when share invitation is accepted. + //TODO new action-service needs to be created. + //TODO add all shared with me agents to installedModels + + // const sharedReferences: string[] = []; //part of TODO uncomment or remove if not needed; + + actions.push( + ...(payload.resources.entities + .map((sharedItem) => { + const sharedModel = modelsMap[sharedItem.id]; + + if (sharedModel) { + // sharedReferences.push(sharedModel.reference); //part of TODO uncomment or remove if not needed; + + return ModelsActions.updateLocalModels({ + reference: sharedModel.reference, + updatedValues: { + sharedWithMe: true, + permissions: sharedItem.permissions, + }, + }); + } + return undefined; + }) + .filter(Boolean) as AnyAction[]), + ); + } + } + return concat(actions); }), ); @@ -780,7 +946,7 @@ const revokeAccessEpic: AppEpic = (action$) => }), ); -const revokeAccessSuccessEpic: AppEpic = (action$) => +const revokeAccessSuccessEpic: AppEpic = (action$, state$) => action$.pipe( filter(ShareActions.revokeAccessSuccess.match), switchMap(({ payload }) => { @@ -836,6 +1002,23 @@ const revokeAccessSuccessEpic: AppEpic = (action$) => ); } + if (payload.featureType === FeatureType.Application) { + const modelsMap = ModelsSelectors.selectModelsMap(state$.value); + const applicationReference = modelsMap[payload.resourceId]?.reference; + + if (!applicationReference) { + return EMPTY; + } + return of( + ModelsActions.updateLocalModels({ + reference: applicationReference, + updatedValues: { + isShared: false, + }, + }), + ); + } + console.error(`Entity not updated: ${payload.resourceId}`); return EMPTY; }), @@ -996,6 +1179,25 @@ const discardSharedWithMeSuccessEpic: AppEpic = (action$, state$) => ); } + if (payload.featureType === FeatureType.Application) { + const modelsMap = ModelsSelectors.selectModelsMap(state$.value); + const applicationReference = modelsMap[payload.resourceId]?.reference; + return concat( + iif( + () => !!applicationReference, + of( + ModelsActions.removeInstalledModels({ + references: [applicationReference ?? ''], + action: DeleteType.DELETE, + }), + ), + EMPTY, + ), + + of(MarketplaceActions.setDetailsModel()), + ); + } + console.error(`Entity not updated: ${payload.resourceId}`); return EMPTY; }), @@ -1050,6 +1252,7 @@ export const ShareEpics = combineEpics( sharePromptEpic, shareConversationFolderEpic, sharePromptFolderEpic, + shareApplicationEpic, acceptInvitationEpic, acceptInvitationSuccessEpic, @@ -1070,6 +1273,7 @@ export const ShareEpics = combineEpics( triggerGettingSharedListingsConversationsEpic, triggerGettingSharedListingsPromptsEpic, triggerGettingSharedListingsAttachmentsEpic, + triggerGettingSharedListingsApplicationsEpic, deleteOrRenameSharedFolderEpic, ); diff --git a/apps/chat/src/store/share/share.reducers.ts b/apps/chat/src/store/share/share.reducers.ts index ae2a7c39da..f094e649c0 100644 --- a/apps/chat/src/store/share/share.reducers.ts +++ b/apps/chat/src/store/share/share.reducers.ts @@ -1,8 +1,13 @@ import { PayloadAction, createSelector, createSlice } from '@reduxjs/toolkit'; import { splitEntityId } from '@/src/utils/app/folders'; -import { parseConversationApiKey } from '@/src/utils/server/api'; +import { hasWritePermission } from '@/src/utils/app/share'; +import { + parseApplicationApiKey, + parseConversationApiKey, +} from '@/src/utils/server/api'; +import { ApplicationInfo } from '@/src/types/applications'; import { FeatureType } from '@/src/types/common'; import { ErrorMessage } from '@/src/types/error'; import { DialFile } from '@/src/types/files'; @@ -13,14 +18,21 @@ import { ShareRelations } from '@/src/types/share'; import { RootState } from '../index'; -import { ConversationInfo, UploadStatus } from '@epam/ai-dial-shared'; +import { + ConversationInfo, + SharePermission, + UploadStatus, +} from '@epam/ai-dial-shared'; export interface ShareState { initialized: boolean; status: UploadStatus; error: ErrorMessage | undefined; invitationId: string | undefined; + writeInvitationId: string | undefined; shareResourceName: string | undefined; + shareResourceVersion: string | undefined; + shareResourceId: string | undefined; shareModalState: ModalState; acceptedId: string | undefined; isFolderAccepted: boolean | undefined; @@ -35,7 +47,10 @@ const initialState: ShareState = { status: UploadStatus.UNINITIALIZED, error: undefined, invitationId: undefined, + writeInvitationId: undefined, shareResourceName: undefined, + shareResourceVersion: undefined, + shareResourceId: undefined, shareModalState: ModalState.CLOSED, acceptedId: undefined, isFolderAccepted: undefined, @@ -61,18 +76,28 @@ export const shareSlice = createSlice({ featureType: FeatureType; resourceId: string; isFolder?: boolean; + permissions?: SharePermission[]; }>, ) => { state.invitationId = undefined; + state.writeInvitationId = undefined; state.shareModalState = ModalState.LOADING; state.shareFeatureType = payload.featureType; state.shareIsFolder = payload.isFolder; + state.shareResourceId = payload.resourceId; const name = splitEntityId(payload.resourceId).name; state.shareResourceName = payload.featureType === FeatureType.Chat ? parseConversationApiKey(splitEntityId(payload.resourceId).name).name - : name; + : payload.featureType === FeatureType.Application + ? parseApplicationApiKey(name).name + : name; + + state.shareResourceVersion = + payload.featureType === FeatureType.Application + ? parseApplicationApiKey(name).version + : undefined; }, sharePrompt: ( state, @@ -98,15 +123,30 @@ export const shareSlice = createSlice({ resourceId: string; }>, ) => state, + shareApplication: ( + state, + _action: PayloadAction<{ + resourceId: string; + permissions?: SharePermission[]; + }>, + ) => { + state.shareModalState = ModalState.LOADING; + }, shareSuccess: ( state, { payload, }: PayloadAction<{ invitationId: string; + permissions?: SharePermission[]; }>, ) => { - state.invitationId = payload.invitationId; + if (hasWritePermission(payload.permissions)) { + state.writeInvitationId = payload.invitationId; + } else { + state.invitationId = payload.invitationId; + } + state.shareModalState = ModalState.OPENED; }, shareFail: (state, _action: PayloadAction) => { @@ -174,6 +214,7 @@ export const shareSlice = createSlice({ isFolder: boolean; isConversation?: boolean; isPrompt?: boolean; + isApplication?: boolean; }>, ) => { state.acceptedId = payload.acceptedId; @@ -183,6 +224,7 @@ export const shareSlice = createSlice({ }, triggerGettingSharedConversationListings: (state) => state, triggerGettingSharedPromptListings: (state) => state, + triggerGettingSharedApplicationsListings: (state) => state, acceptShareInvitationFail: ( state, _action: PayloadAction<{ @@ -208,7 +250,12 @@ export const shareSlice = createSlice({ featureType: FeatureType; sharedWith: ShareRelations; resources: { - entities: (ConversationInfo | Prompt | DialFile)[]; + entities: ( + | ConversationInfo + | Prompt + | DialFile + | Omit + )[]; folders: FolderInterface[]; }; }>, @@ -222,15 +269,28 @@ const rootSelector = (state: RootState): ShareState => state.share; const selectInvitationId = createSelector([rootSelector], (state) => { return state.invitationId; }); + +const selectWriteInvitationId = createSelector([rootSelector], (state) => { + return state.writeInvitationId; +}); + const selectShareModalState = createSelector([rootSelector], (state) => { return state.shareModalState; }); const selectShareModalClosed = createSelector([rootSelector], (state) => { return state.shareModalState === ModalState.CLOSED; }); + +const selectShareResourceId = createSelector([rootSelector], (state) => { + return state.shareResourceId; +}); + const selectShareResourceName = createSelector([rootSelector], (state) => { return state.shareResourceName; }); +const selectShareResourceVersion = createSelector([rootSelector], (state) => { + return state.shareResourceVersion; +}); const selectShareFeatureType = createSelector([rootSelector], (state) => { return state.shareFeatureType; }); @@ -252,9 +312,12 @@ const selectInitialized = createSelector( export const ShareSelectors = { selectInvitationId, + selectWriteInvitationId, selectShareModalState, selectShareModalClosed, selectShareResourceName, + selectShareResourceVersion, + selectShareResourceId, selectAcceptedEntityInfo, selectShareFeatureType, selectShareIsFolder, diff --git a/apps/chat/src/types/applications.ts b/apps/chat/src/types/applications.ts index a091e138e3..eb40e67645 100644 --- a/apps/chat/src/types/applications.ts +++ b/apps/chat/src/types/applications.ts @@ -1,7 +1,7 @@ import { DialAIEntityFeatures, DialAIEntityModel } from './models'; import { QuickAppConfig } from './quick-apps'; -import { Entity } from '@epam/ai-dial-shared'; +import { ShareEntity } from '@epam/ai-dial-shared'; export enum ApplicationStatus { DEPLOYED = 'DEPLOYED', @@ -94,7 +94,7 @@ export type ApiApplicationModel = | ApiApplicationModelFunction | ApiApplicationModelSchema; -export interface ApplicationInfo extends Entity { +export interface ApplicationInfo extends ShareEntity { version: string; } export interface CustomApplicationModel diff --git a/apps/chat/src/types/common.ts b/apps/chat/src/types/common.ts index 9b4f588287..1b7e8cf877 100644 --- a/apps/chat/src/types/common.ts +++ b/apps/chat/src/types/common.ts @@ -1,6 +1,6 @@ import { MappedReplaceActions } from './import-export'; -import { UploadStatus } from '@epam/ai-dial-shared'; +import { SharePermission, UploadStatus } from '@epam/ai-dial-shared'; export enum EntityType { Model = 'model', @@ -35,6 +35,7 @@ export interface BackendDataEntity { bucket: string; parentPath?: string | null; url: string; + permissions?: SharePermission[]; } export interface BackendEntity extends BackendDataEntity { diff --git a/apps/chat/src/types/models.ts b/apps/chat/src/types/models.ts index 898155685c..01f2dd24c4 100644 --- a/apps/chat/src/types/models.ts +++ b/apps/chat/src/types/models.ts @@ -2,7 +2,7 @@ import { ApplicationStatus } from '@/src/types/applications'; import { EntityType } from './common'; -import { EntityPublicationInfo } from '@epam/ai-dial-shared'; +import { EntityPublicationInfo, ShareEntity } from '@epam/ai-dial-shared'; import { TiktokenEncoding } from 'tiktoken'; export type ModelsMap = Partial>; @@ -79,7 +79,9 @@ export interface DialAIEntity { }; } -export interface DialAIEntityModel extends Omit { +export interface DialAIEntityModel + extends Omit, + Omit { limits?: { maxTotalTokens: number; maxResponseTokens: number; diff --git a/apps/chat/src/types/share.ts b/apps/chat/src/types/share.ts index acaae11f10..5a91d02241 100644 --- a/apps/chat/src/types/share.ts +++ b/apps/chat/src/types/share.ts @@ -1,5 +1,7 @@ import { BackendResourceType } from './common'; +import { SharePermission } from '@epam/ai-dial-shared'; + export enum SharingType { Conversation = 'conversation', ConversationFolder = 'conversations_folder', @@ -26,9 +28,13 @@ export enum ShareRequestType { link = 'link', } +export interface ShareResource { + url: string; + permissions?: SharePermission[]; +} export interface ShareRequestModel { invitationType: ShareRequestType; - resources: { url: string }[]; + resources: ShareResource[]; } // Email sharing not implemented on BE diff --git a/apps/chat/src/utils/app/application.ts b/apps/chat/src/utils/app/application.ts index ce92cf2f0b..203346e5a8 100644 --- a/apps/chat/src/utils/app/application.ts +++ b/apps/chat/src/utils/app/application.ts @@ -15,6 +15,7 @@ import { import { EntityType, PartialBy } from '@/src/types/common'; import { DialAIEntityModel } from '@/src/types/models'; import { QuickAppConfig } from '@/src/types/quick-apps'; +import { Translation } from '@/src/types/translation'; import { DESCRIPTION_DELIMITER_REGEX } from '@/src/constants/chat'; import { DEFAULT_TEMPERATURE } from '@/src/constants/default-ui-settings'; @@ -27,6 +28,7 @@ import { ApiUtils, getApplicationApiKey } from '../server/api'; import { constructPath } from './file'; import { getFolderIdFromEntityId } from './folders'; import { getApplicationRootId } from './id'; +import { translate } from './translation'; import omit from 'lodash-es/omit'; @@ -220,3 +222,10 @@ export const isApplicationDeploymentInProgress = ( entity.functionStatus === ApplicationStatus.UNDEPLOYING ); }; + +export const getSharedTooltip = (context: string) => { + return translate( + `You cannot change the ${context} of a shared application.`, + { ns: Translation.Marketplace }, + ); +}; diff --git a/apps/chat/src/utils/app/data/share-service.ts b/apps/chat/src/utils/app/data/share-service.ts index c81b10da3f..5596b9c7db 100644 --- a/apps/chat/src/utils/app/data/share-service.ts +++ b/apps/chat/src/utils/app/data/share-service.ts @@ -1,5 +1,6 @@ import { Observable, map } from 'rxjs'; +import { ApplicationInfo } from '@/src/types/applications'; import { ApiKeys, BackendChatEntity, @@ -22,7 +23,11 @@ import { ShareRevokeRequestModel, } from '@/src/types/share'; -import { ApiUtils, parseConversationApiKey } from '../../server/api'; +import { + ApiUtils, + parseApplicationApiKey, + parseConversationApiKey, +} from '../../server/api'; import { constructPath } from '../file'; import { splitEntityId } from '../folders'; import { EnumMapper } from '../mappers'; @@ -79,7 +84,12 @@ export class ShareService { public static getSharedListing( sharedListingData: ShareListingRequestModel, ): Observable<{ - entities: (ConversationInfo | PromptInfo | DialFile)[]; + entities: ( + | ConversationInfo + | PromptInfo + | DialFile + | Omit + )[]; folders: FolderInterface[]; }> { return ApiUtils.request('/api/share/listing', { @@ -88,7 +98,12 @@ export class ShareService { }).pipe( map((resp: { resources: BackendDataEntity[] }) => { const folders: FolderInterface[] = []; - const entities: (ConversationInfo | PromptInfo | DialFile)[] = []; + const entities: ( + | ConversationInfo + | PromptInfo + | DialFile + | Omit + )[] = []; resp.resources.forEach((entity) => { if (entity.resourceType === BackendResourceType.CONVERSATION) { @@ -175,6 +190,18 @@ export class ShareService { }); } } + + if (entity.resourceType === BackendResourceType.APPLICATION) { + const application = entity as BackendEntity; + const id = ApiUtils.decodeApiUrl(application.url); + + entities.push({ + name: application.name, + version: parseApplicationApiKey(application.name).version, + id, + permissions: application.permissions, + }); + } }); return { diff --git a/apps/chat/src/utils/app/share.ts b/apps/chat/src/utils/app/share.ts index 6fe9fd92d7..8b000b2002 100644 --- a/apps/chat/src/utils/app/share.ts +++ b/apps/chat/src/utils/app/share.ts @@ -1,7 +1,10 @@ import { FeatureType } from '@/src/types/common'; import { DialAIError } from '@/src/types/error'; +import { DialAIEntityModel } from '@/src/types/models'; import { SharingType } from '@/src/types/share'; +import { ShareEntity, SharePermission } from '@epam/ai-dial-shared'; + export const getShareType = ( featureType?: FeatureType, isFolder?: boolean, @@ -25,6 +28,8 @@ export const getShareType = ( return SharingType.Conversation; case FeatureType.Prompt: return SharingType.Prompt; + case FeatureType.Application: + return SharingType.Application; default: return undefined; } @@ -38,3 +43,10 @@ export const validateInvitationId = (invitationId: string) => { throw new DialAIError('Invalid invitationId', '', '', '400'); } }; + +export const hasWritePermission = ( + permissions: SharePermission[] | undefined, +) => permissions?.includes(SharePermission.WRITE); + +export const canWriteSharedWithMe = (entity: DialAIEntityModel | ShareEntity) => + hasWritePermission(entity?.permissions); diff --git a/libs/shared/src/types/chat.ts b/libs/shared/src/types/chat.ts index d620e6eb97..51dd8aed76 100644 --- a/libs/shared/src/types/chat.ts +++ b/libs/shared/src/types/chat.ts @@ -100,6 +100,11 @@ export interface EntityPublicationInfo { versionGroup?: string; } +export enum SharePermission { + READ = 'READ', + WRITE = 'WRITE', +} + export interface ShareInterface { isShared?: boolean; sharedWithMe?: boolean; @@ -107,6 +112,8 @@ export interface ShareInterface { isPublished?: boolean; publishedWithMe?: boolean; publicationInfo?: EntityPublicationInfo; + + permissions?: SharePermission[]; } export interface ShareEntity extends Entity, ShareInterface {} diff --git a/libs/shared/src/types/features.ts b/libs/shared/src/types/features.ts index f60fa7a480..f2bb72f930 100644 --- a/libs/shared/src/types/features.ts +++ b/libs/shared/src/types/features.ts @@ -15,6 +15,7 @@ export enum Feature { Likes = 'likes', // Display likes ConversationsSharing = 'conversations-sharing', // Display conversation sharing PromptsSharing = 'prompts-sharing', // Display prompts sharing + ApplicationsSharing = 'applications-sharing', // Display applications sharing InputFiles = 'input-files', // Allow attach files to conversation InputLinks = 'input-links', // Allow attach links to conversation AttachmentsManager = 'attachments-manager', // Display attachments manager in conversation sidebar @@ -47,6 +48,7 @@ export const availableFeatures: Record = { [Feature.Likes]: true, [Feature.ConversationsSharing]: true, [Feature.PromptsSharing]: true, + [Feature.ApplicationsSharing]: true, [Feature.InputFiles]: true, [Feature.InputLinks]: true, [Feature.AttachmentsManager]: true,