feat: undo-redo & edit snapshots (#2436)

* feat: undo-redo & edit snapshots

* fix merge

* add simple history back

* fix some undo

* change app latest version

* fix

* chatconfig

* fix snapshot

* fix

* fix

* fix

* fix compare

* fix initial

* fix merge:

* fix useEffect

* fix snapshot initial and saved state

* chore

* fix

* compare snapshot

* nodes edges useEffct

* fix chatconfig

* fix

* delete unused method

* fix

* fix

* fix

* default version name
This commit is contained in:
heheer
2024-08-23 15:58:43 +08:00
committed by GitHub
parent de573e4303
commit 6288dc9492
49 changed files with 1559 additions and 349 deletions

View File

@@ -20,6 +20,8 @@ export type PostPublishAppProps = {
nodes: AppSchema['modules'];
edges: AppSchema['edges'];
chatConfig: AppSchema['chatConfig'];
isPublish?: boolean;
versionName?: string;
};
export type PostRevertAppProps = {

View File

@@ -14,6 +14,8 @@ import { defaultNodeVersion } from '@fastgpt/global/core/workflow/node/constant'
import { ClientSession } from '@fastgpt/service/common/mongo';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';
import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema';
import { MongoUser } from '@fastgpt/service/support/user/schema';
export type CreateAppBody = {
parentId?: ParentIdType;
@@ -42,6 +44,8 @@ async function handler(req: ApiRequestProps<CreateAppBody>) {
// 上限校验
await checkTeamAppLimit(teamId);
const tmb = await MongoTeamMember.findById({ _id: tmbId });
const user = await MongoUser.findById({ _id: tmb?.userId });
// 创建app
const appId = await onCreateApp({
@@ -53,7 +57,9 @@ async function handler(req: ApiRequestProps<CreateAppBody>) {
edges,
chatConfig,
teamId,
tmbId
tmbId,
userAvatar: user?.avatar,
username: user?.username
});
return appId;
@@ -73,6 +79,8 @@ export const onCreateApp = async ({
teamId,
tmbId,
pluginData,
username,
userAvatar,
session
}: {
parentId?: ParentIdType;
@@ -86,6 +94,8 @@ export const onCreateApp = async ({
teamId: string;
tmbId: string;
pluginData?: AppSchema['pluginData'];
username?: string;
userAvatar?: string;
session?: ClientSession;
}) => {
const create = async (session: ClientSession) => {
@@ -117,7 +127,10 @@ export const onCreateApp = async ({
appId,
nodes: modules,
edges,
chatConfig
chatConfig,
versionName: name,
username,
avatar: userAvatar
}
],
{ session }

View File

@@ -10,13 +10,18 @@ import { PostPublishAppProps } from '@/global/core/app/api';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
type Response = {};
async function handler(req: NextApiRequest, res: NextApiResponse<any>): Promise<{}> {
const { appId } = req.query as { appId: string };
const { nodes = [], edges = [], chatConfig, type } = req.body as PostPublishAppProps;
const {
nodes = [],
edges = [],
chatConfig,
type,
isPublish,
versionName
} = req.body as PostPublishAppProps;
const { app } = await authApp({ appId, req, per: WritePermissionVal, authToken: true });
const { app, tmbId } = await authApp({ appId, req, per: WritePermissionVal, authToken: true });
const { nodes: formatNodes } = beforeUpdateAppFormat({ nodes });
@@ -28,7 +33,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse<any>): Promise<
appId,
nodes: formatNodes,
edges,
chatConfig
chatConfig,
isPublish,
versionName,
tmbId
}
],
{ session }

View File

@@ -0,0 +1,27 @@
import { NextAPI } from '@/service/middleware/entry';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { ApiRequestProps } from '@fastgpt/service/type/next';
import { MongoAppVersion } from '@fastgpt/service/core/app/version/schema';
export type UpdateAppVersionBody = {
appId: string;
versionId: string;
versionName: string;
};
async function handler(req: ApiRequestProps<UpdateAppVersionBody>) {
const { appId, versionId, versionName } = req.body;
await authApp({ appId, req, per: WritePermissionVal, authToken: true });
await MongoAppVersion.findByIdAndUpdate(
{ _id: versionId },
{
versionName
}
);
return {};
}
export default NextAPI(handler);

View File

@@ -45,7 +45,7 @@ async function handler(
}
// get app and history
const [{ histories }, { nodes }] = await Promise.all([
const [{ histories }, { nodes, chatConfig }] = await Promise.all([
getChatItems({
appId,
chatId,
@@ -68,7 +68,7 @@ async function handler(
history: app.type === AppTypeEnum.plugin ? histories : transformPreviewHistories(histories),
app: {
chatConfig: getAppChatConfig({
chatConfig: app.chatConfig,
chatConfig,
systemConfigNode: getGuideModule(nodes),
storeVariables: chat?.variableList,
storeWelcomeText: chat?.welcomeText,

View File

@@ -42,7 +42,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
throw new Error(ChatErrEnum.unAuthChat);
}
const [{ histories }, { nodes }] = await Promise.all([
const [{ histories }, { nodes, chatConfig }] = await Promise.all([
getChatItems({
appId: app._id,
chatId,
@@ -75,7 +75,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
history: app.type === AppTypeEnum.plugin ? histories : transformPreviewHistories(histories),
app: {
chatConfig: getAppChatConfig({
chatConfig: app.chatConfig,
chatConfig,
systemConfigNode: getGuideModule(nodes),
storeVariables: chat?.variableList,
storeWelcomeText: chat?.welcomeText,

View File

@@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@fastgpt/service/common/response';
import { connectToDatabase } from '@/service/mongo';
import { getGuideModule, getAppChatConfig } from '@fastgpt/global/core/workflow/utils';
import { getChatModelNameListByModules } from '@/service/core/app/workflow';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
@@ -48,7 +47,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
// get app and history
const [{ histories }, { nodes }] = await Promise.all([
const [{ histories }, { nodes, chatConfig }] = await Promise.all([
getChatItems({
appId,
chatId,
@@ -76,7 +75,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
history: app.type === AppTypeEnum.plugin ? histories : transformPreviewHistories(histories),
app: {
chatConfig: getAppChatConfig({
chatConfig: app.chatConfig,
chatConfig,
systemConfigNode: getGuideModule(nodes),
storeVariables: chat?.variableList,
storeWelcomeText: chat?.welcomeText,

View File

@@ -1,68 +1,123 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Flex, Button, IconButton } from '@chakra-ui/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Flex,
Button,
IconButton,
HStack,
ModalBody,
ModalFooter,
useDisclosure
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext, getWorkflowStore } from '../WorkflowComponents/context';
import { useInterval } from 'ahooks';
import { AppContext, TabEnum } from '../context';
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
import RouteTab from '../RouteTab';
import { useRouter } from 'next/router';
import AppCard from '../WorkflowComponents/AppCard';
import { uiWorkflow2StoreWorkflow } from '../WorkflowComponents/utils';
import RouteTab from '../RouteTab';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const PublishHistories = dynamic(() => import('../PublishHistoriesSlider'));
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { compareSnapshot } from '@/web/core/workflow/utils';
import SaveAndPublishModal from '../WorkflowComponents/Flow/components/SaveAndPublish';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
const PublishHistories = dynamic(() => import('../WorkflowPublishHistoriesSlider'));
const Header = () => {
const { t } = useTranslation();
const router = useRouter();
const { isPc } = useSystem();
const router = useRouter();
const { appDetail, onPublish, currentTab } = useContextSelector(AppContext, (v) => v);
const isV2Workflow = appDetail?.version === 'v2';
const { isOpen, onOpen, onClose } = useDisclosure();
const {
isOpen: isSaveAndPublishModalOpen,
onOpen: onSaveAndPublishModalOpen,
onClose: onSaveAndPublishModalClose
} = useDisclosure();
const [isSave, setIsSave] = useState(false);
const {
flowData2StoreData,
flowData2StoreDataAndCheck,
onSaveWorkflow,
setHistoriesDefaultData,
setWorkflowTestData,
setHistoriesDefaultData,
historiesDefaultData,
initData
nodes,
edges,
past,
future,
setPast
} = useContextSelector(WorkflowContext, (v) => v);
const onclickPublish = useCallback(async () => {
const data = flowData2StoreDataAndCheck();
if (data) {
await onPublish({
...data,
chatConfig: appDetail.chatConfig,
//@ts-ignore
version: 'v2'
});
}
}, [flowData2StoreDataAndCheck, onPublish, appDetail.chatConfig]);
const isPublished = useMemo(() => {
const savedSnapshot =
future.findLast((snapshot) => snapshot.isSaved) || past.find((snapshot) => snapshot.isSaved);
const saveAndBack = useCallback(async () => {
return compareSnapshot(
{
nodes: savedSnapshot?.nodes,
edges: savedSnapshot?.edges,
chatConfig: savedSnapshot?.chatConfig
},
{
nodes: nodes,
edges: edges,
chatConfig: appDetail.chatConfig
}
);
}, [future, past, nodes, edges, appDetail.chatConfig]);
const { runAsync: onClickSave, loading } = useRequest2(
async ({
isPublish,
versionName = formatTime2YMDHMS(new Date())
}: {
isPublish?: boolean;
versionName?: string;
}) => {
const data = flowData2StoreData();
if (data) {
await onPublish({
...data,
isPublish,
versionName,
chatConfig: appDetail.chatConfig,
//@ts-ignore
version: 'v2'
});
setPast((prevPast) =>
prevPast.map((item, index) =>
index === prevPast.length - 1
? {
...item,
isSaved: true
}
: item
)
);
}
}
);
const onBack = useCallback(async () => {
try {
await onSaveWorkflow();
localStorage.removeItem(`${appDetail._id}-past`);
localStorage.removeItem(`${appDetail._id}-future`);
router.push('/app/list');
} catch (error) {}
}, [onSaveWorkflow, router]);
// effect
useBeforeunload({
callback: onSaveWorkflow,
tip: t('common:core.common.tip.leave page')
});
useInterval(() => {
if (!appDetail._id) return;
onSaveWorkflow();
}, 40000);
}, [appDetail._id, router]);
const Render = useMemo(() => {
return (
@@ -78,9 +133,10 @@ const Header = () => {
pl={[2, 4]}
pr={[2, 6]}
borderBottom={'base'}
alignItems={'center'}
alignItems={['flex-start', 'center']}
userSelect={'none'}
h={'67px'}
h={['auto', '67px']}
flexWrap={'wrap'}
{...(currentTab === TabEnum.appEdit
? {
bg: 'myGray.25'
@@ -95,15 +151,36 @@ const Header = () => {
name={'common/leftArrowLight'}
w={'1.75rem'}
cursor={'pointer'}
onClick={saveAndBack}
onClick={isPublished ? onBack : onOpen}
/>
<MyModal
isOpen={isOpen}
onClose={onClose}
iconSrc="common/warn"
title={t('common:common.Exit')}
w={'400px'}
>
<ModalBody>
<Box>{t('workflow:workflow.exit_tips')}</Box>
</ModalBody>
<ModalFooter gap={3}>
<Button variant={'whiteDanger'} onClick={onBack}>
{t('common:common.Exit Directly')}
</Button>
<Button
isLoading={loading}
onClick={async () => {
await onClickSave({});
onBack();
}}
>
{t('common:common.Save_and_exit')}
</Button>
</ModalFooter>
</MyModal>
{/* app info */}
<Box ml={1}>
<AppCard
showSaveStatus={
!historiesDefaultData && isV2Workflow && currentTab === TabEnum.appEdit
}
/>
<AppCard isPublished={isPublished} showSaveStatus={isV2Workflow} />
</Box>
{isPc && (
@@ -114,10 +191,9 @@ const Header = () => {
<Box flex={1} />
{currentTab === TabEnum.appEdit && (
<>
<HStack flexDirection={['column', 'row']} spacing={[2, 3]}>
{!historiesDefaultData && (
<IconButton
mr={[2, 4]}
icon={<MyIcon name={'history'} w={'18px'} />}
aria-label={''}
size={'sm'}
@@ -145,52 +221,109 @@ const Header = () => {
}
}}
>
{t('common:core.workflow.run_test')}
{t('common:core.workflow.Run')}
</Button>
{!historiesDefaultData && (
<PopoverConfirm
showCancel
content={t('common:core.app.Publish Confirm')}
<MyPopover
placement={'bottom-end'}
hasArrow={false}
offset={[2, 4]}
w={'116px'}
onOpenFunc={() => setIsSave(true)}
onCloseFunc={() => setIsSave(false)}
trigger={'hover'}
Trigger={
<Button
ml={[2, 4]}
size={'sm'}
leftIcon={<MyIcon name={'common/publishFill'} w={['14px', '16px']} />}
rightIcon={
<MyIcon
name={isSave ? 'core/chat/chevronUp' : 'core/chat/chevronDown'}
w={['14px', '16px']}
/>
}
>
{t('common:core.app.Publish')}
<Box>{t('common:common.Save')}</Box>
</Button>
}
onConfirm={() => onclickPublish()}
/>
>
{({}) => (
<MyBox p={1.5}>
<MyBox
display={'flex'}
size={'md'}
px={1}
py={1.5}
rounded={'4px'}
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
cursor={'pointer'}
isLoading={loading}
onClick={async () => {
await onClickSave({});
}}
>
<MyIcon name={'core/workflow/upload'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('common:core.workflow.Save to cloud')}</Box>
</MyBox>
<Flex
px={1}
py={1.5}
rounded={'4px'}
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
cursor={'pointer'}
onClick={() => {
const data = flowData2StoreDataAndCheck();
if (data) {
onSaveAndPublishModalOpen();
}
}}
>
<MyIcon name={'core/workflow/publish'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('common:core.workflow.Save and publish')}</Box>
{isSaveAndPublishModalOpen && (
<SaveAndPublishModal
isLoading={loading}
onClose={onSaveAndPublishModalClose}
onClickSave={onClickSave}
/>
)}
</Flex>
</MyBox>
)}
</MyPopover>
)}
</>
</HStack>
)}
</Flex>
{historiesDefaultData && (
{historiesDefaultData && isV2Workflow && currentTab === TabEnum.appEdit && (
<PublishHistories
initData={initData}
onClose={() => {
setHistoriesDefaultData(undefined);
}}
defaultData={historiesDefaultData}
/>
)}
</>
);
}, [
appDetail.chatConfig,
currentTab,
flowData2StoreDataAndCheck,
historiesDefaultData,
initData,
isPc,
currentTab,
isPublished,
isOpen,
onClose,
t,
loading,
isV2Workflow,
onclickPublish,
saveAndBack,
historiesDefaultData,
isSave,
onBack,
onOpen,
onClickSave,
setHistoriesDefaultData,
appDetail.chatConfig,
flowData2StoreDataAndCheck,
setWorkflowTestData,
t
isSaveAndPublishModalOpen,
onSaveAndPublishModalClose,
onSaveAndPublishModalOpen
]);
return Render;

View File

@@ -1,23 +1,36 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Flex, Button, IconButton, HStack } from '@chakra-ui/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Flex,
Button,
IconButton,
HStack,
ModalBody,
ModalFooter,
useDisclosure
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext, getWorkflowStore } from '../WorkflowComponents/context';
import { useInterval } from 'ahooks';
import { AppContext, TabEnum } from '../context';
import RouteTab from '../RouteTab';
import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm';
import { useRouter } from 'next/router';
import AppCard from '../WorkflowComponents/AppCard';
import { uiWorkflow2StoreWorkflow } from '../WorkflowComponents/utils';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
const PublishHistories = dynamic(() => import('../PublishHistoriesSlider'));
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { compareSnapshot } from '@/web/core/workflow/utils';
import SaveAndPublishModal from '../WorkflowComponents/Flow/components/SaveAndPublish';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
const PublishHistories = dynamic(() => import('../WorkflowPublishHistoriesSlider'));
const Header = () => {
const { t } = useTranslation();
@@ -26,44 +39,85 @@ const Header = () => {
const { appDetail, onPublish, currentTab } = useContextSelector(AppContext, (v) => v);
const isV2Workflow = appDetail?.version === 'v2';
const { isOpen, onOpen, onClose } = useDisclosure();
const {
isOpen: isSaveAndPublishModalOpen,
onOpen: onSaveAndPublishModalOpen,
onClose: onSaveAndPublishModalClose
} = useDisclosure();
const [isSave, setIsSave] = useState(false);
const {
flowData2StoreData,
flowData2StoreDataAndCheck,
setWorkflowTestData,
onSaveWorkflow,
setHistoriesDefaultData,
historiesDefaultData,
initData
nodes,
edges,
past,
future,
setPast
} = useContextSelector(WorkflowContext, (v) => v);
const onclickPublish = useCallback(async () => {
const data = flowData2StoreDataAndCheck();
if (data) {
await onPublish({
...data,
chatConfig: appDetail.chatConfig,
//@ts-ignore
version: 'v2'
});
}
}, [flowData2StoreDataAndCheck, onPublish, appDetail.chatConfig]);
const isPublished = useMemo(() => {
const savedSnapshot =
future.findLast((snapshot) => snapshot.isSaved) || past.find((snapshot) => snapshot.isSaved);
const saveAndBack = useCallback(async () => {
return compareSnapshot(
{
nodes: savedSnapshot?.nodes,
edges: savedSnapshot?.edges,
chatConfig: savedSnapshot?.chatConfig
},
{
nodes: nodes,
edges: edges,
chatConfig: appDetail.chatConfig
}
);
}, [future, past, nodes, edges, appDetail.chatConfig]);
const { runAsync: onClickSave, loading } = useRequest2(
async ({
isPublish,
versionName = formatTime2YMDHMS(new Date())
}: {
isPublish?: boolean;
versionName?: string;
}) => {
const data = flowData2StoreData();
if (data) {
await onPublish({
...data,
isPublish,
versionName,
chatConfig: appDetail.chatConfig,
//@ts-ignore
version: 'v2'
});
setPast((prevPast) =>
prevPast.map((item, index) =>
index === prevPast.length - 1
? {
...item,
isSaved: true
}
: item
)
);
}
}
);
const onBack = useCallback(async () => {
try {
await onSaveWorkflow();
localStorage.removeItem(`${appDetail._id}-past`);
localStorage.removeItem(`${appDetail._id}-future`);
router.push('/app/list');
} catch (error) {}
}, [onSaveWorkflow, router]);
// effect
useBeforeunload({
callback: onSaveWorkflow,
tip: t('common:core.common.tip.leave page')
});
useInterval(() => {
if (!appDetail._id) return;
onSaveWorkflow();
}, 40000);
}, [appDetail._id, router]);
const Render = useMemo(() => {
return (
@@ -97,15 +151,36 @@ const Header = () => {
name={'common/leftArrowLight'}
w={'1.75rem'}
cursor={'pointer'}
onClick={saveAndBack}
onClick={isPublished ? onBack : onOpen}
/>
<MyModal
isOpen={isOpen}
onClose={onClose}
iconSrc="common/warn"
title={t('common:common.Exit')}
w={'400px'}
>
<ModalBody>
<Box>{t('workflow:workflow.exit_tips')}</Box>
</ModalBody>
<ModalFooter gap={3}>
<Button variant={'whiteDanger'} onClick={onBack}>
{t('common:common.Exit Directly')}
</Button>
<Button
isLoading={loading}
onClick={async () => {
await onClickSave({});
onBack();
}}
>
{t('common:common.Save_and_exit')}
</Button>
</ModalFooter>
</MyModal>
{/* app info */}
<Box ml={1}>
<AppCard
showSaveStatus={
!historiesDefaultData && isV2Workflow && currentTab === TabEnum.appEdit
}
/>
<AppCard isPublished={isPublished} showSaveStatus={isV2Workflow} />
</Box>
{isPc && (
@@ -146,38 +221,84 @@ const Header = () => {
}
}}
>
{t('common:core.workflow.run_test')}
{t('common:core.workflow.Run')}
</Button>
{!historiesDefaultData && (
<PopoverConfirm
showCancel
content={t('common:core.app.Publish Confirm')}
<MyPopover
placement={'bottom-end'}
hasArrow={false}
offset={[2, 4]}
w={'116px'}
onOpenFunc={() => setIsSave(true)}
onCloseFunc={() => setIsSave(false)}
trigger={'hover'}
Trigger={
<Box>
<MyTooltip label={t('common:core.app.Publish app tip')}>
<Button
size={'sm'}
leftIcon={<MyIcon name={'common/publishFill'} w={['14px', '16px']} />}
>
{t('common:core.app.Publish')}
</Button>
</MyTooltip>
</Box>
<Button
size={'sm'}
rightIcon={
<MyIcon
name={isSave ? 'core/chat/chevronUp' : 'core/chat/chevronDown'}
w={['14px', '16px']}
/>
}
>
<Box>{t('common:common.Save')}</Box>
</Button>
}
onConfirm={() => onclickPublish()}
/>
>
{({}) => (
<MyBox p={1.5}>
<MyBox
display={'flex'}
size={'md'}
px={1}
py={1.5}
rounded={'4px'}
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
cursor={'pointer'}
isLoading={loading}
onClick={async () => {
await onClickSave({});
}}
>
<MyIcon name={'core/workflow/upload'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('common:core.workflow.Save to cloud')}</Box>
</MyBox>
<Flex
px={1}
py={1.5}
rounded={'4px'}
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
cursor={'pointer'}
onClick={() => {
const data = flowData2StoreDataAndCheck();
if (data) {
onSaveAndPublishModalOpen();
}
}}
>
<MyIcon name={'core/workflow/publish'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('common:core.workflow.Save and publish')}</Box>
{isSaveAndPublishModalOpen && (
<SaveAndPublishModal
isLoading={loading}
onClose={onSaveAndPublishModalClose}
onClickSave={onClickSave}
/>
)}
</Flex>
</MyBox>
)}
</MyPopover>
)}
</HStack>
)}
</Flex>
{historiesDefaultData && (
{historiesDefaultData && isV2Workflow && currentTab === TabEnum.appEdit && (
<PublishHistories
initData={initData}
onClose={() => {
setHistoriesDefaultData(undefined);
}}
defaultData={historiesDefaultData}
/>
)}
</>
@@ -185,16 +306,24 @@ const Header = () => {
}, [
isPc,
currentTab,
saveAndBack,
historiesDefaultData,
isV2Workflow,
isPublished,
isOpen,
onClose,
t,
initData,
loading,
isV2Workflow,
historiesDefaultData,
isSave,
onBack,
onOpen,
onClickSave,
setHistoriesDefaultData,
appDetail.chatConfig,
flowData2StoreDataAndCheck,
setWorkflowTestData,
onclickPublish
isSaveAndPublishModalOpen,
onSaveAndPublishModalClose,
onSaveAndPublishModalOpen
]);
return Render;

View File

@@ -31,14 +31,15 @@ const WorkflowEdit = () => {
useMount(() => {
if (!isV2Workflow) {
openConfirm(() => {
initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any))));
initData(JSON.parse(JSON.stringify(v1Workflow2V2((appDetail.modules || []) as any))), true);
})();
} else {
initData(
cloneDeep({
nodes: appDetail.modules || [],
edges: appDetail.edges || []
})
}),
true
);
}
});

View File

@@ -2,14 +2,13 @@ import React, { useCallback, useMemo } from 'react';
import { Box, Flex, HStack, useDisclosure } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { AppContext, TabEnum } from '../context';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useI18n } from '@/web/context/I18n';
import { WorkflowContext } from './context';
import { compareWorkflow, filterSensitiveNodesData } from '@/web/core/workflow/utils';
import { filterSensitiveNodesData } from '@/web/core/workflow/utils';
import dynamic from 'next/dynamic';
import { useCopyData } from '@/web/common/hooks/useCopyData';
import { useSystemStore } from '@/web/common/system/useSystemStore';
@@ -18,15 +17,21 @@ import { publishStatusStyle } from '../constants';
const ImportSettings = dynamic(() => import('./Flow/ImportSettings'));
const AppCard = ({ showSaveStatus }: { showSaveStatus: boolean }) => {
const AppCard = ({
showSaveStatus,
isPublished
}: {
showSaveStatus: boolean;
isPublished: boolean;
}) => {
const { t } = useTranslation();
const { appT } = useI18n();
const { copyData } = useCopyData();
const { feConfigs } = useSystemStore();
const { appDetail, appLatestVersion, onOpenInfoEdit, onOpenTeamTagModal, onDelApp, currentTab } =
const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp, currentTab } =
useContextSelector(AppContext, (v) => v);
const { historiesDefaultData, flowData2StoreDataAndCheck, onSaveWorkflow, isSaving, saveLabel } =
const { historiesDefaultData, flowData2StoreDataAndCheck, onSaveWorkflow, isSaving } =
useContextSelector(WorkflowContext, (v) => v);
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure();
@@ -48,27 +53,6 @@ const AppCard = ({ showSaveStatus }: { showSaveStatus: boolean }) => {
}
}, [appDetail.chatConfig, appT, copyData, flowData2StoreDataAndCheck]);
const isPublished = (() => {
const data = flowData2StoreDataAndCheck(true);
if (!appLatestVersion) return true;
if (data) {
return compareWorkflow(
{
nodes: appLatestVersion.nodes,
edges: appLatestVersion.edges,
chatConfig: appLatestVersion.chatConfig
},
{
nodes: data.nodes,
edges: data.edges,
chatConfig: appDetail.chatConfig
}
);
}
return false;
})();
const InfoMenu = useCallback(
({ children }: { children: React.ReactNode }) => {
return (
@@ -169,35 +153,25 @@ const AppCard = ({ showSaveStatus }: { showSaveStatus: boolean }) => {
</HStack>
</InfoMenu>
{showSaveStatus && (
<MyTooltip label={t('common:core.app.Onclick to save')}>
<Flex
alignItems={'center'}
h={'20px'}
cursor={'pointer'}
fontSize={'mini'}
onClick={onSaveWorkflow}
lineHeight={1}
<Flex alignItems={'center'} h={'20px'} fontSize={'mini'} lineHeight={1}>
<MyTag
py={0}
px={0}
showDot
bg={'transparent'}
colorSchema={
isPublished
? publishStatusStyle.published.colorSchema
: publishStatusStyle.unPublish.colorSchema
}
>
{isSaving && <MyIcon name={'common/loading'} w={'0.8rem'} mr={0.5} />}
<Box color={'myGray.500'}>{saveLabel}</Box>
<MyTag
py={0}
showDot
bg={'transparent'}
colorSchema={
isPublished
? publishStatusStyle.published.colorSchema
: publishStatusStyle.unPublish.colorSchema
}
>
{t(
isPublished
? publishStatusStyle.published.text
: publishStatusStyle.unPublish.text
)}
</MyTag>
</Flex>
</MyTooltip>
{t(
isPublished
? publishStatusStyle.published.text
: publishStatusStyle.unPublish.text
)}
</MyTag>
</Flex>
)}
</Box>
@@ -210,10 +184,7 @@ const AppCard = ({ showSaveStatus }: { showSaveStatus: boolean }) => {
appDetail.name,
isOpenImport,
isPublished,
isSaving,
onCloseImport,
onSaveWorkflow,
saveLabel,
showSaveStatus,
t
]);

View File

@@ -93,15 +93,16 @@ const ChatTest = ({
</Flex>
) : (
<Flex
py={4}
py={2.5}
px={5}
whiteSpace={'nowrap'}
bg={isPlugin ? 'myGray.25' : ''}
borderBottom={isPlugin ? '1px solid #F4F4F7' : ''}
bg={'myGray.25'}
borderBottom={'1px solid #F4F4F7'}
>
<Box fontSize={'lg'} fontWeight={'bold'} flex={1}>
{t('common:core.chat.Debug test')}
</Box>
<Flex fontSize={'16px'} fontWeight={'bold'} flex={1} alignItems={'center'}>
<MyIcon name={'common/paused'} w={'14px'} mr={2.5} />
{t('common:core.chat.Run test')}
</Flex>
<MyTooltip label={t('common:core.chat.Restart')}>
<IconButton
className="chat"
@@ -121,6 +122,7 @@ const ChatTest = ({
size={'smSquare'}
aria-label={''}
onClick={onClose}
bg={'none'}
/>
</MyTooltip>
</Flex>

View File

@@ -388,13 +388,12 @@ const RenderList = React.memo(function RenderList({
setParentId
}: RenderListProps) {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { feConfigs, setLoading } = useSystemStore();
const { isPc } = useSystem();
const isSystemPlugin = type === TemplateTypeEnum.systemPlugin;
const { x, y, zoom } = useViewport();
const { setLoading } = useSystemStore();
const { toast } = useToast();
const reactFlowWrapper = useContextSelector(WorkflowContext, (v) => v.reactFlowWrapper);
const setNodes = useContextSelector(WorkflowContext, (v) => v.setNodes);
@@ -466,15 +465,16 @@ const RenderList = React.memo(function RenderList({
selected: true
});
setNodes((state) =>
state
setNodes((state) => {
const newState = state
.map((node) => ({
...node,
selected: false
}))
// @ts-ignore
.concat(node)
);
.concat(node);
return newState;
});
},
[computedNewNodeName, reactFlowWrapper, setLoading, setNodes, t, toast, x, y, zoom]
);

View File

@@ -30,7 +30,10 @@ const ButtonEdge = (props: EdgeProps) => {
const onDelConnect = useCallback(
(id: string) => {
setEdges((state) => state.filter((item) => item.id !== id));
setEdges((state) => {
const newState = state.filter((item) => item.id !== id);
return newState;
});
},
[setEdges]
);

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useMemo } from 'react';
import { Background, ControlButton, MiniMap, Panel, useReactFlow } from 'reactflow';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { Box } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import styles from './index.module.scss';
const FlowController = React.memo(function FlowController() {
const { fitView, zoomIn, zoomOut } = useReactFlow();
const { undo, redo, canRedo, canUndo } = useContextSelector(WorkflowContext, (v) => v);
const { t } = useTranslation();
const isMac = !window ? false : window.navigator.userAgent.toLocaleLowerCase().includes('mac');
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'z' && (event.ctrlKey || event.metaKey) && event.shiftKey) {
event.preventDefault();
redo();
} else if (event.key === 'z' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
undo();
} else if ((event.key === '=' || event.key === '+') && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
zoomIn();
} else if (event.key === '-' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
zoomOut();
}
};
document.addEventListener('keydown', keyDownHandler);
return () => {
document.removeEventListener('keydown', keyDownHandler);
};
}, [undo, redo, zoomIn, zoomOut]);
const buttonStyle = {
border: 'none',
borderRadius: '6px',
padding: '7px'
};
const Render = useMemo(() => {
return (
<>
<MiniMap
style={{
height: 98,
width: 184,
marginBottom: 72,
borderRadius: '10px',
boxShadow: '0px 0px 1px rgba(19, 51, 107, 0.10), 0px 4px 10px rgba(19, 51, 107, 0.10)'
}}
pannable
/>
<Panel
position={'bottom-right'}
style={{
display: 'flex',
marginBottom: 24,
padding: '5px 8px',
background: 'white',
borderRadius: '6px',
overflow: 'hidden',
alignItems: 'center',
gap: '2px',
boxShadow:
'0px 0px 1px 0px rgba(19, 51, 107, 0.20), 0px 12px 16px -4px rgba(19, 51, 107, 0.20)'
}}
>
{/* undo */}
<MyTooltip label={isMac ? t('common:common.undo_tip_mac') : t('common:common.undo_tip')}>
<ControlButton
onClick={undo}
style={buttonStyle}
className={`${styles.customControlButton}`}
disabled={!canUndo}
>
<MyIcon name={'core/workflow/undo'} />
</ControlButton>
</MyTooltip>
{/* redo */}
<MyTooltip label={isMac ? t('common:common.redo_tip_mac') : t('common:common.redo_tip')}>
<ControlButton
onClick={redo}
style={buttonStyle}
className={`${styles.customControlButton}`}
disabled={!canRedo}
>
<MyIcon name={'core/workflow/redo'} />
</ControlButton>
</MyTooltip>
<Box w="1px" h="20px" bg="gray.200" mx={1.5}></Box>
{/* zoom out */}
<MyTooltip
label={isMac ? t('common:common.zoomout_tip_mac') : t('common:common.zoomout_tip')}
>
<ControlButton
onClick={() => zoomOut()}
style={buttonStyle}
className={`${styles.customControlButton}`}
>
<MyIcon name={'common/subtract'} />
</ControlButton>
</MyTooltip>
{/* zoom in */}
<MyTooltip
label={isMac ? t('common:common.zoomin_tip_mac') : t('common:common.zoomin_tip')}
>
<ControlButton
onClick={() => zoomIn()}
style={buttonStyle}
className={`${styles.customControlButton}`}
>
<MyIcon name={'common/addLight'} />
</ControlButton>
</MyTooltip>
<Box w="1px" h="20px" bg="gray.200" mx={1.5}></Box>
{/* fit view */}
<MyTooltip label={t('common:common.page_center')}>
<ControlButton
onClick={() => fitView()}
style={buttonStyle}
className={`custom-workflow-fix_view ${styles.customControlButton}`}
>
<MyIcon name={'core/modules/fixview'} />
</ControlButton>
</MyTooltip>
</Panel>
<Background />
</>
);
}, [isMac, t, undo, buttonStyle, canUndo, redo, canRedo, zoomOut, zoomIn, fitView]);
return Render;
});
export default FlowController;

View File

@@ -0,0 +1,74 @@
import { Box, Button, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type FormType = {
versionName: string;
isPublish: boolean | undefined;
};
const SaveAndPublishModal = ({
onClose,
isLoading,
onClickSave
}: {
onClose: () => void;
isLoading: boolean;
onClickSave: (data: { isPublish: boolean; versionName: string }) => Promise<void>;
}) => {
const { t } = useTranslation();
const { register, handleSubmit } = useForm<FormType>({
defaultValues: {
versionName: '',
isPublish: undefined
}
});
return (
<MyModal
title={t('common:core.workflow.Save and publish')}
iconSrc={'core/workflow/publish'}
maxW={'400px'}
isOpen
onClose={onClose}
>
<ModalBody>
<Box mb={2.5} color={'myGray.900'} fontSize={'14px'} fontWeight={'500'}>
{t('common:common.Name')}
</Box>
<Box mb={3}>
<Input
autoFocus
placeholder={t('app:app.Version name')}
bg={'myWhite.600'}
{...register('versionName', {
required: t('app:app.version_name_tips')
})}
/>
</Box>
<Box fontSize={'14px'}>{t('app:app.version_publish_tips')}</Box>
</ModalBody>
<ModalFooter gap={3}>
<Button
onClick={() => {
onClose();
}}
variant={'whiteBase'}
>
{t('common:common.Cancel')}
</Button>
<Button
isLoading={isLoading}
onClick={handleSubmit(async (data) => {
await onClickSave({ ...data, isPublish: true });
onClose();
})}
>
{t('common:common.Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
export default SaveAndPublishModal;

View File

@@ -0,0 +1,8 @@
.customControlButton {
svg {
width: 18px;
height: 18px;
max-width: 18px;
max-height: 18px;
}
}

View File

@@ -6,7 +6,6 @@ import {
addEdge,
EdgeChange,
Edge,
applyNodeChanges,
Node,
NodePositionChange,
XYPosition
@@ -15,7 +14,6 @@ import { EDGE_TYPE } from '@fastgpt/global/core/workflow/node/constant';
import 'reactflow/dist/style.css';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useTranslation } from 'next-i18next';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useKeyboard } from './useKeyboard';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
@@ -259,10 +257,6 @@ const computeHelperLines = (
export const useWorkflow = () => {
const { toast } = useToast();
const { t } = useTranslation();
const { openConfirm: onOpenConfirmDeleteNode, ConfirmModal: ConfirmDeleteModal } = useConfirm({
content: t('common:core.module.Confirm Delete Node'),
type: 'delete'
});
const { isDowningCtrl } = useKeyboard();
const {
@@ -329,7 +323,7 @@ export const useWorkflow = () => {
title: t('common:core.workflow.Can not delete node')
});
} else {
return onOpenConfirmDeleteNode(() => {
return (() => {
onNodesChange(changes);
setEdges((state) =>
state.filter((edge) => edge.source !== change.id && edge.target !== change.id)
@@ -387,6 +381,7 @@ export const useWorkflow = () => {
title: t('common:core.module.Can not connect self')
});
}
onConnect({
connect
});
@@ -406,7 +401,6 @@ export const useWorkflow = () => {
}, [setHoverEdgeId]);
return {
ConfirmDeleteModal,
handleNodesChange,
handleEdgeChange,
onConnectStart,
@@ -416,9 +410,7 @@ export const useWorkflow = () => {
onEdgeMouseEnter,
onEdgeMouseLeave,
helperLineHorizontal,
setHelperLineHorizontal,
helperLineVertical,
setHelperLineVertical
helperLineVertical
};
};

View File

@@ -1,13 +1,5 @@
import React, { useMemo } from 'react';
import ReactFlow, {
Background,
Controls,
ControlButton,
MiniMap,
NodeProps,
ReactFlowProvider,
useReactFlow
} from 'reactflow';
import React from 'react';
import ReactFlow, { NodeProps, ReactFlowProvider } from 'reactflow';
import { Box, IconButton, useDisclosure } from '@chakra-ui/react';
import { SmallCloseIcon } from '@chakra-ui/icons';
import { EDGE_TYPE, FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
@@ -19,14 +11,12 @@ import NodeTemplatesModal from './NodeTemplatesModal';
import 'reactflow/dist/style.css';
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { connectionLineStyle, defaultEdgeOptions } from '../constants';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../context';
import { useWorkflow } from './hooks/useWorkflow';
import { t } from 'i18next';
import HelperLines from './components/HelperLines';
import FlowController from './components/FlowController';
const NodeSimple = dynamic(() => import('./nodes/NodeSimple'));
const nodeTypes: Record<FlowNodeTypeEnum, any> = {
@@ -67,7 +57,6 @@ const Workflow = () => {
const { nodes, edges, reactFlowWrapper } = useContextSelector(WorkflowContext, (v) => v);
const {
ConfirmDeleteModal,
handleNodesChange,
handleEdgeChange,
onConnectStart,
@@ -142,8 +131,6 @@ const Workflow = () => {
<HelperLines horizontal={helperLineHorizontal} vertical={helperLineVertical} />
</ReactFlow>
</Box>
<ConfirmDeleteModal />
</>
);
};
@@ -157,45 +144,3 @@ const Render = () => {
};
export default React.memo(Render);
const FlowController = React.memo(function FlowController() {
const { fitView } = useReactFlow();
const Render = useMemo(() => {
return (
<>
<MiniMap
style={{
height: 78,
width: 126,
marginBottom: 35
}}
pannable
/>
<Controls
position={'bottom-right'}
style={{
display: 'flex',
marginBottom: 5,
background: 'white',
borderRadius: '6px',
overflow: 'hidden',
boxShadow:
'0px 0px 1px 0px rgba(19, 51, 107, 0.20), 0px 12px 16px -4px rgba(19, 51, 107, 0.20)'
}}
showInteractive={false}
showFitView={false}
>
<MyTooltip label={t('common:common.page_center')}>
<ControlButton className="custom-workflow-fix_view" onClick={() => fitView()}>
<MyIcon name={'core/modules/fixview'} w={'14px'} />
</ControlButton>
</MyTooltip>
</Controls>
<Background />
</>
);
}, [fitView]);
return Render;
});

View File

@@ -8,7 +8,6 @@ import TTSSelect from '@/components/core/app/TTSSelect';
import WhisperConfig from '@/components/core/app/WhisperConfig';
import InputGuideConfig from '@/components/core/app/InputGuideConfig';
import { getAppChatConfig } from '@fastgpt/global/core/workflow/utils';
import { useTranslation } from 'next-i18next';
import { TTSTypeEnum } from '@/web/core/app/constants';
import NodeCard from './render/NodeCard';
import ScheduledTriggerConfig from '@/components/core/app/ScheduledTriggerConfig';
@@ -90,14 +89,12 @@ const NodeUserGuide = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
export default React.memo(NodeUserGuide);
function WelcomeText({ chatConfig: { welcomeText }, setAppDetail }: ComponentProps) {
const { t } = useTranslation();
const [, startTst] = useTransition();
return (
<Box className="nodrag">
<WelcomeTextConfig
resize={'both'}
defaultValue={welcomeText}
value={welcomeText}
onChange={(e) => {
startTst(() => {
setAppDetail((state) => ({
@@ -164,8 +161,6 @@ function TTSGuide({ chatConfig: { ttsConfig }, setAppDetail }: ComponentProps) {
}
function WhisperGuide({ chatConfig: { whisperConfig, ttsConfig }, setAppDetail }: ComponentProps) {
const onChangeNode = useContextSelector(WorkflowContext, (v) => v.onChangeNode);
return (
<WhisperConfig
isOpenAudio={ttsConfig?.type !== TTSTypeEnum.none}
@@ -205,7 +200,6 @@ function ScheduledTrigger({
function QuestionInputGuide({ chatConfig: { chatInputGuide }, setAppDetail }: ComponentProps) {
const appId = useContextSelector(WorkflowContext, (v) => v.appId);
return appId ? (
<InputGuideConfig
appId={appId}

View File

@@ -386,7 +386,7 @@ const MenuRender = React.memo(function MenuRender({
icon: 'delete',
label: t('common:common.Delete'),
variant: 'whiteDanger',
onClick: onOpenConfirmDeleteNode(() => onDelNode(nodeId))
onClick: () => onDelNode(nodeId)
}
])
];
@@ -429,7 +429,6 @@ const MenuRender = React.memo(function MenuRender({
menuForbid?.copy,
menuForbid?.delete,
t,
onOpenConfirmDeleteNode,
ConfirmDeleteModal,
DebugInputModal,
openDebugNode,

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useMemo } from 'react';
import type { RenderInputProps } from '../type';
import { useTranslation } from 'next-i18next';
import PromptEditor from '@fastgpt/web/components/common/Textarea/PromptEditor';
import { formatEditorVariablePickerIcon } from '@fastgpt/global/core/workflow/utils';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '@/pages/app/detail/components/WorkflowComponents/context';
import { computedNodeInputReference } from '@/web/core/workflow/utils';

View File

@@ -1,6 +1,7 @@
import { postWorkflowDebug } from '@/web/core/workflow/api';
import {
checkWorkflowNodeAndConnection,
compareSnapshot,
storeEdgesRenderEdge,
storeNode2FlowNode
} from '@/web/core/workflow/utils';
@@ -14,7 +15,7 @@ import { RuntimeEdgeItemType, StoreEdgeItemType } from '@fastgpt/global/core/wor
import { FlowNodeChangeProps } from '@fastgpt/global/core/workflow/type/fe';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useMemoizedFn, useUpdateEffect } from 'ahooks';
import { useLocalStorageState, useMemoizedFn, useUpdateEffect } from 'ahooks';
import React, {
Dispatch,
SetStateAction,
@@ -45,12 +46,19 @@ import { useDisclosure } from '@chakra-ui/react';
import { uiWorkflow2StoreWorkflow } from './utils';
import { useTranslation } from 'next-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { formatTime2HM, formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import { formatTime2YMDHMS, formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import type { InitProps } from '@/pages/app/detail/components/PublishHistoriesSlider';
import { cloneDeep } from 'lodash';
import { cloneDeep, isEqual } from 'lodash';
type OnChange<ChangesType> = (changes: ChangesType[]) => void;
export type SnapshotsType = {
nodes: Node[];
edges: Edge[];
title: string;
chatConfig: AppChatConfigType;
isSaved?: boolean;
};
type WorkflowContextType = {
appId?: string;
basicNodeTemplates: FlowNodeTemplateType[];
@@ -82,6 +90,27 @@ type WorkflowContextType = {
hoverEdgeId?: string;
setHoverEdgeId: React.Dispatch<React.SetStateAction<string | undefined>>;
// snapshots
saveSnapshot: ({
pastNodes,
pastEdges,
customTitle,
chatConfig
}: {
pastNodes?: Node[];
pastEdges?: Edge[];
customTitle?: string;
chatConfig?: AppChatConfigType;
}) => Promise<boolean>;
resetSnapshot: (state: SnapshotsType) => void;
past: SnapshotsType[];
setPast: Dispatch<SetStateAction<SnapshotsType[]>>;
future: SnapshotsType[];
redo: () => void;
undo: () => void;
canRedo: boolean;
canUndo: boolean;
// connect
connectingEdge?: OnConnectStartParams;
setConnectingEdge: React.Dispatch<React.SetStateAction<OnConnectStartParams | undefined>>;
@@ -96,19 +125,27 @@ type WorkflowContextType = {
toolInputs: FlowNodeInputItemType[];
commonInputs: FlowNodeInputItemType[];
};
initData: (e: {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig?: AppChatConfigType;
}) => Promise<void>;
initData: (
e: {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
chatConfig?: AppChatConfigType;
},
isSetInitial?: boolean
) => Promise<void>;
flowData2StoreDataAndCheck: (hideTip?: boolean) =>
| {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
}
| undefined;
flowData2StoreData: () =>
| {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
}
| undefined;
onSaveWorkflow: () => Promise<null | undefined>;
saveLabel: string;
isSaving: boolean;
// debug
@@ -252,17 +289,40 @@ export const WorkflowContext = createContext<WorkflowContextType>({
| undefined {
throw new Error('Function not implemented.');
},
flowData2StoreData: function ():
| { nodes: StoreNodeItemType[]; edges: StoreEdgeItemType[] }
| undefined {
throw new Error('Function not implemented.');
},
onSaveWorkflow: function (): Promise<null | undefined> {
throw new Error('Function not implemented.');
},
saveLabel: '',
historiesDefaultData: undefined,
setHistoriesDefaultData: function (value: React.SetStateAction<InitProps | undefined>): void {
throw new Error('Function not implemented.');
},
getNodeDynamicInputs: function (nodeId: string): FlowNodeInputItemType[] {
throw new Error('Function not implemented.');
}
},
saveSnapshot: function (): Promise<boolean> {
throw new Error('Function not implemented.');
},
resetSnapshot: function (): void {
throw new Error('Function not implemented.');
},
past: [],
setPast: function (): void {
throw new Error('Function not implemented.');
},
future: [],
redo: function (): void {
throw new Error('Function not implemented.');
},
undo: function (): void {
throw new Error('Function not implemented.');
},
canRedo: false,
canUndo: false
});
const WorkflowContextProvider = ({
@@ -363,8 +423,8 @@ const WorkflowContextProvider = ({
const onChangeNode = useMemoizedFn((props: FlowNodeChangeProps) => {
const { nodeId, type } = props;
setNodes((nodes) =>
nodes.map((node) => {
setNodes((nodes) => {
const newNodes = nodes.map((node) => {
if (node.id !== nodeId) return node;
const updateObj = cloneDeep(node.data);
@@ -429,8 +489,9 @@ const WorkflowContextProvider = ({
...node,
data: updateObj
};
})
);
});
return newNodes;
});
});
const getNodeDynamicInputs = useCallback(
(nodeId: string) => {
@@ -469,18 +530,35 @@ const WorkflowContextProvider = ({
};
};
const initData = useMemoizedFn(async (e: Parameters<WorkflowContextType['initData']>[0]) => {
setNodes(e.nodes?.map((item) => storeNode2FlowNode({ item })) || []);
setEdges(e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []);
const initData = useMemoizedFn(
async (e: Parameters<WorkflowContextType['initData']>[0], isInit?: boolean) => {
setNodes(e.nodes?.map((item) => storeNode2FlowNode({ item })) || []);
setEdges(e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []);
const chatConfig = e.chatConfig;
if (chatConfig) {
setAppDetail((state) => ({
...state,
chatConfig
}));
const chatConfig = e.chatConfig;
if (chatConfig) {
setAppDetail((state) => ({
...state,
chatConfig
}));
}
// If it is the initial data, save the initial snapshot
if (!isInit) return;
// If it has been initialized, it will not be saved
if (past.length > 0) {
resetSnapshot(past[0]);
return;
}
saveSnapshot({
pastNodes: e.nodes?.map((item) => storeNode2FlowNode({ item })) || [],
pastEdges: e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || [],
customTitle: t(`app:app.version_initial`),
chatConfig: appDetail.chatConfig,
isSaved: true
});
}
});
);
/* ui flow to store data */
const flowData2StoreDataAndCheck = useMemoizedFn((hideTip = false) => {
@@ -499,8 +577,13 @@ const WorkflowContextProvider = ({
}
});
const flowData2StoreData = useMemoizedFn(() => {
const storeNodes = uiWorkflow2StoreWorkflow({ nodes, edges });
return storeNodes;
});
/* save workflow */
const [saveLabel, setSaveLabel] = useState(t('common:core.app.Onclick to save'));
const { runAsync: onSaveWorkflow, loading: isSaving } = useRequest2(async () => {
const { nodes } = await getWorkflowStore();
@@ -522,11 +605,6 @@ const WorkflowContextProvider = ({
//@ts-ignore
version: 'v2'
});
setSaveLabel(
t('common:core.app.Saved time', {
time: formatTime2HM()
})
);
} catch (error) {}
return null;
@@ -750,6 +828,121 @@ const WorkflowContextProvider = ({
onOpenTest();
}, [workflowTestData]);
/* snapshots */
const [past, setPast] = useLocalStorageState<SnapshotsType[]>(`${appId}-past`, {
defaultValue: []
}) as [SnapshotsType[], (value: SetStateAction<SnapshotsType[]>) => void];
const [future, setFuture] = useLocalStorageState<SnapshotsType[]>(`${appId}-future`, {
defaultValue: []
}) as [SnapshotsType[], (value: SetStateAction<SnapshotsType[]>) => void];
const resetSnapshot = useCallback(
(state: SnapshotsType) => {
setNodes(state.nodes);
setEdges(state.edges);
setAppDetail((detail) => ({
...detail,
chatConfig: state.chatConfig
}));
},
[setAppDetail, setEdges, setNodes]
);
const { runAsync: saveSnapshot } = useRequest2(
async ({
pastNodes,
pastEdges,
customTitle,
chatConfig,
isSaved
}: {
pastNodes?: Node[];
pastEdges?: Edge[];
customTitle?: string;
chatConfig?: AppChatConfigType;
isSaved?: boolean;
}) => {
const pastState = past[0];
const currentNodes = pastNodes || nodes;
const currentEdges = pastEdges || edges;
const currentChatConfig = chatConfig || appDetail.chatConfig;
const isPastEqual = compareSnapshot(
{
nodes: currentNodes,
edges: currentEdges,
chatConfig: currentChatConfig
},
{
nodes: pastState?.nodes,
edges: pastState?.edges,
chatConfig: pastState?.chatConfig
}
);
if (isPastEqual) return false;
setPast((past) => [
{
nodes: currentNodes,
edges: currentEdges,
title: customTitle || formatTime2YMDHMS(new Date()),
chatConfig: currentChatConfig,
isSaved
},
...past.slice(0, 199)
]);
setFuture([]);
return true;
},
{
debounceWait: 500,
refreshDeps: [nodes, edges, appDetail.chatConfig, past]
}
);
useEffect(() => {
if (!nodes.length) return;
saveSnapshot({
pastNodes: nodes,
pastEdges: edges,
customTitle: formatTime2YMDHMS(new Date()),
chatConfig: appDetail.chatConfig
});
}, [nodes, edges, appDetail.chatConfig]);
const undo = useCallback(() => {
if (past[1]) {
setFuture((future) => [past[0], ...future]);
setPast((past) => past.slice(1));
resetSnapshot(past[1]);
}
}, [past, setFuture, setPast, resetSnapshot]);
const redo = useCallback(() => {
const futureState = future[0];
if (futureState) {
setPast((past) => [future[0], ...past]);
setFuture((future) => future.slice(1));
resetSnapshot(futureState);
}
}, [future, setPast, setFuture, resetSnapshot]);
// remove other app's snapshot
useEffect(() => {
const keys = Object.keys(localStorage);
const snapshotKeys = keys.filter((key) => key.endsWith('-past') || key.endsWith('-future'));
snapshotKeys.forEach((key) => {
const keyAppId = key.split('-')[0];
if (keyAppId !== appId) {
localStorage.removeItem(key);
}
});
}, [appId]);
const value = {
appId,
reactFlowWrapper,
@@ -777,14 +970,25 @@ const WorkflowContextProvider = ({
setConnectingEdge,
onDelEdge,
// snapshots
past,
setPast,
future,
undo,
redo,
saveSnapshot,
resetSnapshot,
canUndo: past.length > 1,
canRedo: !!future.length,
// function
onFixView,
splitToolInputs,
initData,
flowData2StoreDataAndCheck,
flowData2StoreData,
onSaveWorkflow,
isSaving,
saveLabel,
// debug
workflowDebugData,

View File

@@ -0,0 +1,330 @@
import React, { useState } from 'react';
import { getPublishList, updateAppVersion } from '@/web/core/app/api/version';
import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination';
import CustomRightDrawer from '@fastgpt/web/components/common/MyDrawer/CustomRightDrawer';
import { useTranslation } from 'next-i18next';
import { Box, Button, Flex, Input } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from './context';
import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs';
import { WorkflowContext } from './WorkflowComponents/context';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import Avatar from '@fastgpt/web/components/common/Avatar';
import Tag from '@fastgpt/web/components/common/Tag';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { storeEdgesRenderEdge, storeNode2FlowNode } from '@/web/core/workflow/utils';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useToast } from '@fastgpt/web/hooks/useToast';
const WorkflowPublishHistoriesSlider = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState<'myEdit' | 'teamCloud'>('myEdit');
return (
<>
<CustomRightDrawer
onClose={() => onClose()}
title={
(
<>
<LightRowTabs
list={[
{ label: t('workflow:workflow.My edit'), value: 'myEdit' },
{ label: t('workflow:workflow.Team cloud'), value: 'teamCloud' }
]}
value={currentTab}
onChange={setCurrentTab}
inlineStyles={{ px: 0.5, pb: 2 }}
gap={5}
py={0}
fontSize={'sm'}
/>
</>
) as any
}
maxW={'340px'}
px={0}
showMask={false}
top={'60px'}
overflow={'unset'}
>
{currentTab === 'myEdit' ? <MyEdit /> : <TeamCloud />}
</CustomRightDrawer>
</>
);
};
export default React.memo(WorkflowPublishHistoriesSlider);
const MyEdit = () => {
const { past, saveSnapshot, resetSnapshot } = useContextSelector(WorkflowContext, (v) => v);
const { t } = useTranslation();
const { toast } = useToast();
return (
<Flex px={5} flex={'1 0 0'} flexDirection={'column'}>
{past.length > 0 && (
<Box py={2} px={3}>
<Button
variant={'whiteBase'}
w={'full'}
h={'30px'}
onClick={async () => {
const initialSnapshot = past[past.length - 1];
const res = await saveSnapshot({
pastNodes: initialSnapshot.nodes,
pastEdges: initialSnapshot.edges,
chatConfig: initialSnapshot.chatConfig,
customTitle: t(`app:app.version_initial_copy`)
});
if (!res) return;
resetSnapshot(initialSnapshot);
toast({
title: t('workflow:workflow.Switch_success'),
status: 'success'
});
}}
>
{t('app:app.version_back')}
</Button>
</Box>
)}
<Flex flex={'1 0 0'} flexDirection={'column'} overflow={'auto'}>
{past.map((item, index) => {
return (
<Flex
key={index}
alignItems={'center'}
py={2}
px={3}
borderRadius={'md'}
cursor={'pointer'}
fontWeight={500}
_hover={{
bg: 'primary.50'
}}
onClick={async () => {
const res = await saveSnapshot({
pastNodes: item.nodes,
pastEdges: item.edges,
chatConfig: item.chatConfig,
customTitle: `${t('app:app.version_copy')}-${item.title}`
});
if (!res) return;
resetSnapshot(item);
toast({
title: t('workflow:workflow.Switch_success'),
status: 'success'
});
}}
>
<Box
w={'12px'}
h={'12px'}
borderWidth={'2px'}
borderColor={'primary.600'}
borderRadius={'50%'}
position={'relative'}
{...(index !== past.length - 1 && {
_after: {
content: '""',
height: '26px',
width: '2px',
bgColor: 'myGray.250',
position: 'absolute',
top: '10px',
left: '3px'
}
})}
></Box>
<Box
ml={3}
flex={'1 0 0'}
fontSize={'sm'}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
color={'myGray.900'}
>
{item.title}
</Box>
</Flex>
);
})}
<Box py={2} textAlign={'center'} color={'myGray.600'} fontSize={'xs'}>
{t('common:common.No more data')}
</Box>
</Flex>
</Flex>
);
};
const TeamCloud = () => {
const { t } = useTranslation();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const { saveSnapshot, resetSnapshot } = useContextSelector(WorkflowContext, (v) => v);
const { loadAndGetTeamMembers } = useUserStore();
const { feConfigs } = useSystemStore();
const { list, ScrollList, isLoading, fetchData } = useScrollPagination(getPublishList, {
itemHeight: 40,
overscan: 20,
pageSize: 30,
defaultParams: {
appId: appDetail._id
}
});
const { data: members = [] } = useRequest2(loadAndGetTeamMembers, {
manual: !feConfigs.isPlus
});
const [editIndex, setEditIndex] = useState<number | undefined>(undefined);
const [hoveredIndex, setHoveredIndex] = useState<number | undefined>(undefined);
const [isEditing, setIsEditing] = useState(false);
const { toast } = useToast();
return (
<ScrollList isLoading={isLoading} flex={'1 0 0'} px={5}>
{list.map((data, index) => {
const item = data.data;
const firstPublishedIndex = list.findIndex((data) => data.data.isPublish);
const tmb = members.find((member) => member.tmbId === item.tmbId);
return (
<Flex
key={data.index}
alignItems={'center'}
py={editIndex !== index ? 2 : 1}
px={3}
borderRadius={'md'}
cursor={'pointer'}
fontWeight={500}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(undefined)}
_hover={{
bg: 'primary.50'
}}
onClick={async () => {
const state = {
nodes: item.nodes?.map((item) => storeNode2FlowNode({ item })),
edges: item.edges?.map((item) => storeEdgesRenderEdge({ edge: item })),
title: item.versionName,
chatConfig: item.chatConfig
};
const res = await saveSnapshot({
pastNodes: state.nodes,
pastEdges: state.edges,
chatConfig: state.chatConfig,
customTitle: `${t('app:app.version_copy')}-${state.title}`
});
if (!res) return;
resetSnapshot(state);
toast({
title: t('workflow:workflow.Switch_success'),
status: 'success'
});
}}
>
<MyPopover
trigger="hover"
placement={'bottom-end'}
w={'208px'}
h={'72px'}
Trigger={
<Box>
<Avatar src={tmb?.avatar} borderRadius={'50%'} w={'24px'} h={'24px'} />
</Box>
}
>
{({ onClose }) => (
<Flex alignItems={'center'} h={'full'} pl={5} gap={3}>
<Box>
<Avatar src={tmb?.avatar} borderRadius={'50%'} w={'36px'} h={'36px'} />
</Box>
<Box>
<Box fontSize={'14px'} color={'myGray.900'}>
{tmb?.memberName}
</Box>
<Box fontSize={'12px'} color={'myGray.500'}>
{formatTime2YMDHMS(item.time)}
</Box>
</Box>
</Flex>
)}
</MyPopover>
{editIndex !== index ? (
<>
<Box
ml={3}
flex={'1 0 0'}
fontSize={'sm'}
display="flex"
alignItems="center"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<Box minWidth={0} overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
<Box as={'span'} color={'myGray.900'}>
{item.versionName || formatTime2YMDHMS(item.time)}
</Box>
</Box>
{item.isPublish && (
<Tag
ml={3}
flexShrink={0}
type="borderSolid"
colorSchema={index === firstPublishedIndex ? 'green' : 'blue'}
>
{index === firstPublishedIndex
? t('app:app.version_current')
: t('app:app.version_past')}
</Tag>
)}
</Box>
{hoveredIndex === index && (
<MyIcon
name="edit"
w={'18px'}
ml={2}
_hover={{ color: 'primary.600' }}
onClick={(e) => {
e.stopPropagation();
setEditIndex(index);
}}
/>
)}
</>
) : (
<MyBox ml={3} isLoading={isEditing} size={'md'}>
<Input
autoFocus
h={8}
defaultValue={item.versionName || formatTime2YMDHMS(item.time)}
onBlur={async (e) => {
setIsEditing(true);
await updateAppVersion({
appId: item.appId,
versionName: e.target.value,
versionId: item._id
});
await fetchData();
setEditIndex(undefined);
setIsEditing(false);
}}
/>
</MyBox>
)}
</Flex>
);
})}
</ScrollList>
);
};

View File

@@ -20,11 +20,11 @@ export const workflowBoxStyles: FlexProps = {
export const publishStatusStyle = {
unPublish: {
colorSchema: 'adora' as any,
text: i18nT('common:core.app.not_published')
text: i18nT('common:core.app.not_saved')
},
published: {
colorSchema: 'green' as any,
text: i18nT('common:core.app.have_publish')
text: i18nT('common:core.app.have_saved')
}
};

View File

@@ -6,13 +6,12 @@ import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import { AppChatConfigType, AppDetailType } from '@fastgpt/global/core/app/type';
import { AppUpdateParams, PostPublishAppProps } from '@/global/core/app/api';
import { postPublishApp } from '@/web/core/app/api/version';
import { postPublishApp, getAppLatestVersion } from '@/web/core/app/api/version';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import dynamic from 'next/dynamic';
import { useDisclosure } from '@chakra-ui/react';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { useI18n } from '@/web/context/I18n';
import { getAppLatestVersion } from '@/web/core/app/api/version';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';

View File

@@ -6,6 +6,7 @@ import type {
getLatestVersionQuery,
getLatestVersionResponse
} from '@/pages/api/core/app/version/latest';
import { UpdateAppVersionBody } from '@/pages/api/core/app/version/update';
export const getAppLatestVersion = (data: getLatestVersionQuery) =>
GET<getLatestVersionResponse>('/core/app/version/latest', data);
@@ -18,3 +19,6 @@ export const getPublishList = (data: PaginationProps<{ appId: string }>) =>
export const postRevertVersion = (appId: string, data: PostRevertAppProps) =>
POST(`/core/app/version/revert?appId=${appId}`, data);
export const updateAppVersion = (data: UpdateAppVersionBody) =>
POST(`/core/app/version/update`, data);

View File

@@ -512,3 +512,111 @@ export const compareWorkflow = (workflow1: WorkflowType, workflow2: WorkflowType
return isEqual(node1, node2);
};
export const compareSnapshot = (
snapshot1: {
nodes: Node<FlowNodeItemType, string | undefined>[] | undefined;
edges: Edge<any>[] | undefined;
chatConfig: AppChatConfigType | undefined;
},
snapshot2: {
nodes: Node<FlowNodeItemType, string | undefined>[];
edges: Edge<any>[];
chatConfig: AppChatConfigType;
}
) => {
const clone1 = cloneDeep(snapshot1);
const clone2 = cloneDeep(snapshot2);
if (!clone1.nodes || !clone2.nodes) return false;
const formatEdge = (edges: Edge[] | undefined) => {
if (!edges) return [];
return edges.map((edge) => ({
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: edge.type
}));
};
if (!isEqual(formatEdge(clone1.edges), formatEdge(clone2.edges))) {
console.log('Edge not equal');
return false;
}
if (
clone1.chatConfig &&
clone2.chatConfig &&
!isEqual(
{
welcomeText: clone1.chatConfig?.welcomeText || '',
variables: clone1.chatConfig?.variables || [],
questionGuide: clone1.chatConfig?.questionGuide || false,
ttsConfig: clone1.chatConfig?.ttsConfig || undefined,
whisperConfig: clone1.chatConfig?.whisperConfig || undefined,
scheduledTriggerConfig: clone1.chatConfig?.scheduledTriggerConfig || undefined,
chatInputGuide: clone1.chatConfig?.chatInputGuide || undefined,
fileSelectConfig: clone1.chatConfig?.fileSelectConfig || undefined
},
{
welcomeText: clone2.chatConfig?.welcomeText || '',
variables: clone2.chatConfig?.variables || [],
questionGuide: clone2.chatConfig?.questionGuide || false,
ttsConfig: clone2.chatConfig?.ttsConfig || undefined,
whisperConfig: clone2.chatConfig?.whisperConfig || undefined,
scheduledTriggerConfig: clone2.chatConfig?.scheduledTriggerConfig || undefined,
chatInputGuide: clone2.chatConfig?.chatInputGuide || undefined,
fileSelectConfig: clone2.chatConfig?.fileSelectConfig || undefined
}
)
) {
console.log('chatConfig not equal');
return false;
}
const formatNodes = (nodes: Node[]) => {
return nodes
.filter((node) => {
if (!node) return;
if (FlowNodeTypeEnum.systemConfig === node.type) return;
return true;
})
.map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: {
id: node.data.id,
flowNodeType: node.data.flowNodeType,
inputs: node.data.inputs.map((input: FlowNodeInputItemType) => ({
key: input.key,
selectedTypeIndex: input.selectedTypeIndex ?? 0,
renderTypeLis: input.renderTypeList,
valueType: input.valueType,
value: input.value ?? undefined
})),
outputs: node.data.outputs.map((item: FlowNodeOutputItemType) => ({
key: item.key,
type: item.type,
value: item.value ?? undefined
})),
name: node.data.name,
intro: node.data.intro,
avatar: node.data.avatar,
version: node.data.version
}
}));
};
const node1 = formatNodes(clone1.nodes);
const node2 = formatNodes(clone2.nodes);
node1.forEach((node, i) => {
if (!isEqual(node, node2[i])) {
console.log('node not equal');
}
});
return isEqual(node1, node2);
};