From 6288dc9492e9a9cdec52ea93e59eaaa79759c61c Mon Sep 17 00:00:00 2001 From: heheer Date: Fri, 23 Aug 2024 15:58:43 +0800 Subject: [PATCH] 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 --- packages/global/common/string/time.ts | 2 + packages/global/core/app/version.d.ts | 3 + packages/service/core/app/controller.ts | 3 +- packages/service/core/app/version/schema.ts | 10 + .../web/components/common/Icon/constants.ts | 7 + .../common/Icon/icons/common/paused.svg | 3 + .../common/Icon/icons/common/subtract.svg | 3 + .../common/Icon/icons/common/warn.svg | 3 + .../Icon/icons/core/modules/fixview.svg | 10 +- .../Icon/icons/core/workflow/publish.svg | 3 + .../common/Icon/icons/core/workflow/redo.svg | 3 + .../common/Icon/icons/core/workflow/undo.svg | 3 + .../Icon/icons/core/workflow/upload.svg | 3 + .../web/components/common/MyPopover/index.tsx | 7 +- .../web/components/common/MyTooltip/index.tsx | 5 +- packages/web/i18n/en/app.json | 12 +- packages/web/i18n/en/common.json | 19 +- packages/web/i18n/en/workflow.json | 9 +- packages/web/i18n/zh/app.json | 12 +- packages/web/i18n/zh/common.json | 22 +- packages/web/i18n/zh/workflow.json | 9 +- projects/app/src/global/core/app/api.d.ts | 2 + projects/app/src/pages/api/core/app/create.ts | 17 +- .../src/pages/api/core/app/version/publish.ts | 18 +- .../src/pages/api/core/app/version/update.ts | 27 ++ projects/app/src/pages/api/core/chat/init.ts | 4 +- .../src/pages/api/core/chat/outLink/init.ts | 4 +- .../app/src/pages/api/core/chat/team/init.ts | 5 +- .../app/detail/components/Plugin/Header.tsx | 267 ++++++++++---- .../app/detail/components/Workflow/Header.tsx | 257 ++++++++++---- .../app/detail/components/Workflow/index.tsx | 5 +- .../components/WorkflowComponents/AppCard.tsx | 85 ++--- .../WorkflowComponents/Flow/ChatTest.tsx | 14 +- .../Flow/NodeTemplatesModal.tsx | 12 +- .../Flow/components/ButtonEdge.tsx | 5 +- .../Flow/components/FlowController.tsx | 149 ++++++++ .../Flow/components/SaveAndPublish.tsx | 74 ++++ .../Flow/components/index.module.scss | 8 + .../Flow/hooks/useWorkflow.tsx | 14 +- .../WorkflowComponents/Flow/index.tsx | 61 +--- .../Flow/nodes/NodeSystemConfig.tsx | 8 +- .../Flow/nodes/render/NodeCard.tsx | 3 +- .../render/RenderInput/templates/Textarea.tsx | 1 - .../components/WorkflowComponents/context.tsx | 268 ++++++++++++-- .../WorkflowPublishHistoriesSlider.tsx | 330 ++++++++++++++++++ .../pages/app/detail/components/constants.tsx | 4 +- .../pages/app/detail/components/context.tsx | 3 +- projects/app/src/web/core/app/api/version.ts | 4 + projects/app/src/web/core/workflow/utils.ts | 108 ++++++ 49 files changed, 1559 insertions(+), 349 deletions(-) create mode 100644 packages/web/components/common/Icon/icons/common/paused.svg create mode 100644 packages/web/components/common/Icon/icons/common/subtract.svg create mode 100644 packages/web/components/common/Icon/icons/common/warn.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/publish.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/redo.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/undo.svg create mode 100644 packages/web/components/common/Icon/icons/core/workflow/upload.svg create mode 100644 projects/app/src/pages/api/core/app/version/update.ts create mode 100644 projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/FlowController.tsx create mode 100644 projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/SaveAndPublish.tsx create mode 100644 projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/index.module.scss create mode 100644 projects/app/src/pages/app/detail/components/WorkflowPublishHistoriesSlider.tsx diff --git a/packages/global/common/string/time.ts b/packages/global/common/string/time.ts index 97e215ff1..2ae2980fd 100644 --- a/packages/global/common/string/time.ts +++ b/packages/global/common/string/time.ts @@ -7,6 +7,8 @@ dayjs.extend(utc); dayjs.extend(timezone); export const formatTime2YMDHMW = (time?: Date) => dayjs(time).format('YYYY-MM-DD HH:mm:ss dddd'); +export const formatTime2YMDHMS = (time?: Date) => + time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : ''; export const formatTime2YMDHM = (time?: Date) => time ? dayjs(time).format('YYYY-MM-DD HH:mm') : ''; export const formatTime2YMD = (time?: Date) => (time ? dayjs(time).format('YYYY-MM-DD') : ''); diff --git a/packages/global/core/app/version.d.ts b/packages/global/core/app/version.d.ts index ff7622541..6d2266727 100644 --- a/packages/global/core/app/version.d.ts +++ b/packages/global/core/app/version.d.ts @@ -8,4 +8,7 @@ export type AppVersionSchemaType = { nodes: AppSchema['modules']; edges: AppSchema['edges']; chatConfig: AppSchema['chatConfig']; + isPublish?: boolean; + versionName: string; + tmbId: string; }; diff --git a/packages/service/core/app/controller.ts b/packages/service/core/app/controller.ts index 68b2e7bb7..2d777bda4 100644 --- a/packages/service/core/app/controller.ts +++ b/packages/service/core/app/controller.ts @@ -48,7 +48,8 @@ export const beforeUpdateAppFormat = { const version = await MongoAppVersion.findOne({ - appId + appId, + isPublish: true }).sort({ time: -1 }); diff --git a/packages/service/core/app/version/schema.ts b/packages/service/core/app/version/schema.ts index e0eceb449..4acf91094 100644 --- a/packages/service/core/app/version/schema.ts +++ b/packages/service/core/app/version/schema.ts @@ -25,6 +25,16 @@ const AppVersionSchema = new Schema({ }, chatConfig: { type: chatConfigType + }, + isPublish: { + type: Boolean + }, + versionName: { + type: String, + default: '' + }, + tmbId: { + type: String } }); diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 1528b7d83..0b4bafaa2 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -46,6 +46,7 @@ export const iconPaths = { 'common/openai': () => import('./icons/common/openai.svg'), 'common/overviewLight': () => import('./icons/common/overviewLight.svg'), 'common/paramsLight': () => import('./icons/common/paramsLight.svg'), + 'common/paused': () => import('./icons/common/paused.svg'), 'common/playFill': () => import('./icons/common/playFill.svg'), 'common/playLight': () => import('./icons/common/playLight.svg'), 'common/publishFill': () => import('./icons/common/publishFill.svg'), @@ -61,6 +62,7 @@ export const iconPaths = { 'common/select': () => import('./icons/common/select.svg'), 'common/selectLight': () => import('./icons/common/selectLight.svg'), 'common/settingLight': () => import('./icons/common/settingLight.svg'), + 'common/subtract': () => import('./icons/common/subtract.svg'), 'common/text/t': () => import('./icons/common/text/t.svg'), 'common/tickFill': () => import('./icons/common/tickFill.svg'), 'common/trash': () => import('./icons/common/trash.svg'), @@ -68,6 +70,7 @@ export const iconPaths = { 'common/uploadFileFill': () => import('./icons/common/uploadFileFill.svg'), 'common/viewLight': () => import('./icons/common/viewLight.svg'), 'common/voiceLight': () => import('./icons/common/voiceLight.svg'), + 'common/warn': () => import('./icons/common/warn.svg'), 'common/wechatFill': () => import('./icons/common/wechatFill.svg'), copy: () => import('./icons/copy.svg'), 'core/app/aiFill': () => import('./icons/core/app/aiFill.svg'), @@ -184,6 +187,8 @@ export const iconPaths = { import('./icons/core/workflow/inputType/selectLLM.svg'), 'core/workflow/inputType/switch': () => import('./icons/core/workflow/inputType/switch.svg'), 'core/workflow/inputType/textarea': () => import('./icons/core/workflow/inputType/textarea.svg'), + 'core/workflow/publish': () => import('./icons/core/workflow/publish.svg'), + 'core/workflow/redo': () => import('./icons/core/workflow/redo.svg'), 'core/workflow/revertVersion': () => import('./icons/core/workflow/revertVersion.svg'), 'core/workflow/runError': () => import('./icons/core/workflow/runError.svg'), 'core/workflow/runSkip': () => import('./icons/core/workflow/runSkip.svg'), @@ -232,6 +237,8 @@ export const iconPaths = { import('./icons/core/workflow/template/variableUpdate.svg'), 'core/workflow/template/workflowStart': () => import('./icons/core/workflow/template/workflowStart.svg'), + 'core/workflow/undo': () => import('./icons/core/workflow/undo.svg'), + 'core/workflow/upload': () => import('./icons/core/workflow/upload.svg'), 'core/workflow/versionHistories': () => import('./icons/core/workflow/versionHistories.svg'), date: () => import('./icons/date.svg'), delete: () => import('./icons/delete.svg'), diff --git a/packages/web/components/common/Icon/icons/common/paused.svg b/packages/web/components/common/Icon/icons/common/paused.svg new file mode 100644 index 000000000..ae8930334 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/paused.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/subtract.svg b/packages/web/components/common/Icon/icons/common/subtract.svg new file mode 100644 index 000000000..bba799acd --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/subtract.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/warn.svg b/packages/web/components/common/Icon/icons/common/warn.svg new file mode 100644 index 000000000..a078940cb --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/warn.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/modules/fixview.svg b/packages/web/components/common/Icon/icons/core/modules/fixview.svg index abaeabba5..0d26f707a 100644 --- a/packages/web/components/common/Icon/icons/core/modules/fixview.svg +++ b/packages/web/components/common/Icon/icons/core/modules/fixview.svg @@ -1,5 +1,7 @@ - - - + + + + + + \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/workflow/publish.svg b/packages/web/components/common/Icon/icons/core/workflow/publish.svg new file mode 100644 index 000000000..829c7edf1 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/publish.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/redo.svg b/packages/web/components/common/Icon/icons/core/workflow/redo.svg new file mode 100644 index 000000000..f96d2bd06 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/undo.svg b/packages/web/components/common/Icon/icons/core/workflow/undo.svg new file mode 100644 index 000000000..8b67ad32c --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/core/workflow/upload.svg b/packages/web/components/common/Icon/icons/core/workflow/upload.svg new file mode 100644 index 000000000..1dbbb8ed7 --- /dev/null +++ b/packages/web/components/common/Icon/icons/core/workflow/upload.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/web/components/common/MyPopover/index.tsx b/packages/web/components/common/MyPopover/index.tsx index cd14c244e..06e7a5b8d 100644 --- a/packages/web/components/common/MyPopover/index.tsx +++ b/packages/web/components/common/MyPopover/index.tsx @@ -17,6 +17,7 @@ interface Props extends PopoverContentProps { hasArrow?: boolean; children: (e: { onClose: () => void }) => React.ReactNode; onCloseFunc?: () => void; + onOpenFunc?: () => void; closeOnBlur?: boolean; } @@ -27,6 +28,7 @@ const MyPopover = ({ trigger, hasArrow = true, children, + onOpenFunc, onCloseFunc, closeOnBlur = false, ...props @@ -39,7 +41,10 @@ const MyPopover = ({ { + onOpen(); + onOpenFunc && onOpenFunc(); + }} onClose={() => { onClose(); onCloseFunc && onCloseFunc(); diff --git a/packages/web/components/common/MyTooltip/index.tsx b/packages/web/components/common/MyTooltip/index.tsx index 93c9c4b13..55ec5d386 100644 --- a/packages/web/components/common/MyTooltip/index.tsx +++ b/packages/web/components/common/MyTooltip/index.tsx @@ -1,12 +1,9 @@ import React from 'react'; -import { Box, Tooltip, TooltipProps } from '@chakra-ui/react'; -import { useSystem } from '../../../hooks/useSystem'; +import { Tooltip, TooltipProps } from '@chakra-ui/react'; interface Props extends TooltipProps {} const MyTooltip = ({ children, shouldWrapChildren = true, ...props }: Props) => { - const { isPc } = useSystem(); - return ( ) { // 上限校验 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) { 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 } diff --git a/projects/app/src/pages/api/core/app/version/publish.ts b/projects/app/src/pages/api/core/app/version/publish.ts index d2b6d76f5..a1517f4b7 100644 --- a/projects/app/src/pages/api/core/app/version/publish.ts +++ b/projects/app/src/pages/api/core/app/version/publish.ts @@ -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): 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): Promise< appId, nodes: formatNodes, edges, - chatConfig + chatConfig, + isPublish, + versionName, + tmbId } ], { session } diff --git a/projects/app/src/pages/api/core/app/version/update.ts b/projects/app/src/pages/api/core/app/version/update.ts new file mode 100644 index 000000000..7febaa045 --- /dev/null +++ b/projects/app/src/pages/api/core/app/version/update.ts @@ -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) { + 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); diff --git a/projects/app/src/pages/api/core/chat/init.ts b/projects/app/src/pages/api/core/chat/init.ts index ca990df55..6e141fccf 100644 --- a/projects/app/src/pages/api/core/chat/init.ts +++ b/projects/app/src/pages/api/core/chat/init.ts @@ -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, diff --git a/projects/app/src/pages/api/core/chat/outLink/init.ts b/projects/app/src/pages/api/core/chat/outLink/init.ts index 74795b35c..d2863d0e3 100644 --- a/projects/app/src/pages/api/core/chat/outLink/init.ts +++ b/projects/app/src/pages/api/core/chat/outLink/init.ts @@ -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, diff --git a/projects/app/src/pages/api/core/chat/team/init.ts b/projects/app/src/pages/api/core/chat/team/init.ts index 1b9cb95e0..b1548634e 100644 --- a/projects/app/src/pages/api/core/chat/team/init.ts +++ b/projects/app/src/pages/api/core/chat/team/init.ts @@ -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, diff --git a/projects/app/src/pages/app/detail/components/Plugin/Header.tsx b/projects/app/src/pages/app/detail/components/Plugin/Header.tsx index fdcdeffc8..3448ca0a4 100644 --- a/projects/app/src/pages/app/detail/components/Plugin/Header.tsx +++ b/projects/app/src/pages/app/detail/components/Plugin/Header.tsx @@ -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} /> + + + {t('workflow:workflow.exit_tips')} + + + + + + {/* app info */} - + {isPc && ( @@ -114,10 +191,9 @@ const Header = () => { {currentTab === TabEnum.appEdit && ( - <> + {!historiesDefaultData && ( } aria-label={''} size={'sm'} @@ -145,52 +221,109 @@ const Header = () => { } }} > - {t('common:core.workflow.run_test')} + {t('common:core.workflow.Run')} - {!historiesDefaultData && ( - setIsSave(true)} + onCloseFunc={() => setIsSave(false)} + trigger={'hover'} Trigger={ } + rightIcon={ + + } > - {t('common:core.app.Publish')} + {t('common:common.Save')} } - onConfirm={() => onclickPublish()} - /> + > + {({}) => ( + + { + await onClickSave({}); + }} + > + + {t('common:core.workflow.Save to cloud')} + + { + const data = flowData2StoreDataAndCheck(); + if (data) { + onSaveAndPublishModalOpen(); + } + }} + > + + {t('common:core.workflow.Save and publish')} + {isSaveAndPublishModalOpen && ( + + )} + + + )} + )} - + )} - {historiesDefaultData && ( + {historiesDefaultData && isV2Workflow && currentTab === TabEnum.appEdit && ( { 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; diff --git a/projects/app/src/pages/app/detail/components/Workflow/Header.tsx b/projects/app/src/pages/app/detail/components/Workflow/Header.tsx index 66eda1784..3448ca0a4 100644 --- a/projects/app/src/pages/app/detail/components/Workflow/Header.tsx +++ b/projects/app/src/pages/app/detail/components/Workflow/Header.tsx @@ -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} /> + + + {t('workflow:workflow.exit_tips')} + + + + + + {/* app info */} - + {isPc && ( @@ -146,38 +221,84 @@ const Header = () => { } }} > - {t('common:core.workflow.run_test')} + {t('common:core.workflow.Run')} - {!historiesDefaultData && ( - setIsSave(true)} + onCloseFunc={() => setIsSave(false)} + trigger={'hover'} Trigger={ - - - } - > - {t('common:core.app.Publish')} - - - + + } + > + {t('common:common.Save')} + } - onConfirm={() => onclickPublish()} - /> + > + {({}) => ( + + { + await onClickSave({}); + }} + > + + {t('common:core.workflow.Save to cloud')} + + { + const data = flowData2StoreDataAndCheck(); + if (data) { + onSaveAndPublishModalOpen(); + } + }} + > + + {t('common:core.workflow.Save and publish')} + {isSaveAndPublishModalOpen && ( + + )} + + + )} + )} )} - {historiesDefaultData && ( + {historiesDefaultData && isV2Workflow && currentTab === TabEnum.appEdit && ( { 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; diff --git a/projects/app/src/pages/app/detail/components/Workflow/index.tsx b/projects/app/src/pages/app/detail/components/Workflow/index.tsx index b71f6b9b5..f046da564 100644 --- a/projects/app/src/pages/app/detail/components/Workflow/index.tsx +++ b/projects/app/src/pages/app/detail/components/Workflow/index.tsx @@ -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 ); } }); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/AppCard.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/AppCard.tsx index 8dd537572..73a435610 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/AppCard.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/AppCard.tsx @@ -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 }) => { {showSaveStatus && ( - - + - {isSaving && } - {saveLabel} - - {t( - isPublished - ? publishStatusStyle.published.text - : publishStatusStyle.unPublish.text - )} - - - + {t( + isPublished + ? publishStatusStyle.published.text + : publishStatusStyle.unPublish.text + )} + + )} @@ -210,10 +184,7 @@ const AppCard = ({ showSaveStatus }: { showSaveStatus: boolean }) => { appDetail.name, isOpenImport, isPublished, - isSaving, onCloseImport, - onSaveWorkflow, - saveLabel, showSaveStatus, t ]); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/ChatTest.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/ChatTest.tsx index 440e4cbf7..76e9b9d13 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/ChatTest.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/ChatTest.tsx @@ -93,15 +93,16 @@ const ChatTest = ({ ) : ( - - {t('common:core.chat.Debug test')} - + + + {t('common:core.chat.Run test')} + diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/NodeTemplatesModal.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/NodeTemplatesModal.tsx index 78747b4d9..9d262d9ac 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/NodeTemplatesModal.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/NodeTemplatesModal.tsx @@ -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] ); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ButtonEdge.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ButtonEdge.tsx index 8f25483a7..428edf478 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ButtonEdge.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ButtonEdge.tsx @@ -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] ); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/FlowController.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/FlowController.tsx new file mode 100644 index 000000000..bd13ee06d --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/FlowController.tsx @@ -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 ( + <> + + + {/* undo */} + + + + + + + {/* redo */} + + + + + + + + + {/* zoom out */} + + zoomOut()} + style={buttonStyle} + className={`${styles.customControlButton}`} + > + + + + + {/* zoom in */} + + zoomIn()} + style={buttonStyle} + className={`${styles.customControlButton}`} + > + + + + + + + {/* fit view */} + + fitView()} + style={buttonStyle} + className={`custom-workflow-fix_view ${styles.customControlButton}`} + > + + + + + + + ); + }, [isMac, t, undo, buttonStyle, canUndo, redo, canRedo, zoomOut, zoomIn, fitView]); + + return Render; +}); + +export default FlowController; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/SaveAndPublish.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/SaveAndPublish.tsx new file mode 100644 index 000000000..a77386b5c --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/SaveAndPublish.tsx @@ -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; +}) => { + const { t } = useTranslation(); + const { register, handleSubmit } = useForm({ + defaultValues: { + versionName: '', + isPublish: undefined + } + }); + + return ( + + + + {t('common:common.Name')} + + + + + {t('app:app.version_publish_tips')} + + + + + + + ); +}; + +export default SaveAndPublishModal; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/index.module.scss b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/index.module.scss new file mode 100644 index 000000000..79566e8bf --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/index.module.scss @@ -0,0 +1,8 @@ +.customControlButton { + svg { + width: 18px; + height: 18px; + max-width: 18px; + max-height: 18px; + } +} diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx index 26205283b..9af63244e 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/hooks/useWorkflow.tsx @@ -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 }; }; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx index 5bdc5cf80..764934483 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/index.tsx @@ -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 = { @@ -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 = () => { - - ); }; @@ -157,45 +144,3 @@ const Render = () => { }; export default React.memo(Render); - -const FlowController = React.memo(function FlowController() { - const { fitView } = useReactFlow(); - - const Render = useMemo(() => { - return ( - <> - - - - fitView()}> - - - - - - - ); - }, [fitView]); - - return Render; -}); diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeSystemConfig.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeSystemConfig.tsx index f7e00e45e..c108795e7 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeSystemConfig.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/NodeSystemConfig.tsx @@ -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) => { export default React.memo(NodeUserGuide); function WelcomeText({ chatConfig: { welcomeText }, setAppDetail }: ComponentProps) { - const { t } = useTranslation(); const [, startTst] = useTransition(); - return ( { 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 ( v.appId); - return appId ? ( onDelNode(nodeId)) + onClick: () => onDelNode(nodeId) } ]) ]; @@ -429,7 +429,6 @@ const MenuRender = React.memo(function MenuRender({ menuForbid?.copy, menuForbid?.delete, t, - onOpenConfirmDeleteNode, ConfirmDeleteModal, DebugInputModal, openDebugNode, diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Textarea.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Textarea.tsx index 71913b141..f1d50a2c4 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Textarea.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/nodes/render/RenderInput/templates/Textarea.tsx @@ -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'; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx index fa31d0ce5..735d461a2 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/context.tsx @@ -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 = (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>; + // snapshots + saveSnapshot: ({ + pastNodes, + pastEdges, + customTitle, + chatConfig + }: { + pastNodes?: Node[]; + pastEdges?: Edge[]; + customTitle?: string; + chatConfig?: AppChatConfigType; + }) => Promise; + resetSnapshot: (state: SnapshotsType) => void; + past: SnapshotsType[]; + setPast: Dispatch>; + future: SnapshotsType[]; + redo: () => void; + undo: () => void; + canRedo: boolean; + canUndo: boolean; + // connect connectingEdge?: OnConnectStartParams; setConnectingEdge: React.Dispatch>; @@ -96,19 +125,27 @@ type WorkflowContextType = { toolInputs: FlowNodeInputItemType[]; commonInputs: FlowNodeInputItemType[]; }; - initData: (e: { - nodes: StoreNodeItemType[]; - edges: StoreEdgeItemType[]; - chatConfig?: AppChatConfigType; - }) => Promise; + initData: ( + e: { + nodes: StoreNodeItemType[]; + edges: StoreEdgeItemType[]; + chatConfig?: AppChatConfigType; + }, + isSetInitial?: boolean + ) => Promise; flowData2StoreDataAndCheck: (hideTip?: boolean) => | { nodes: StoreNodeItemType[]; edges: StoreEdgeItemType[]; } | undefined; + flowData2StoreData: () => + | { + nodes: StoreNodeItemType[]; + edges: StoreEdgeItemType[]; + } + | undefined; onSaveWorkflow: () => Promise; - saveLabel: string; isSaving: boolean; // debug @@ -252,17 +289,40 @@ export const WorkflowContext = createContext({ | undefined { throw new Error('Function not implemented.'); }, + flowData2StoreData: function (): + | { nodes: StoreNodeItemType[]; edges: StoreEdgeItemType[] } + | undefined { + throw new Error('Function not implemented.'); + }, onSaveWorkflow: function (): Promise { throw new Error('Function not implemented.'); }, - saveLabel: '', historiesDefaultData: undefined, setHistoriesDefaultData: function (value: React.SetStateAction): void { throw new Error('Function not implemented.'); }, getNodeDynamicInputs: function (nodeId: string): FlowNodeInputItemType[] { throw new Error('Function not implemented.'); - } + }, + saveSnapshot: function (): Promise { + 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[0]) => { - setNodes(e.nodes?.map((item) => storeNode2FlowNode({ item })) || []); - setEdges(e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []); + const initData = useMemoizedFn( + async (e: Parameters[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(`${appId}-past`, { + defaultValue: [] + }) as [SnapshotsType[], (value: SetStateAction) => void]; + + const [future, setFuture] = useLocalStorageState(`${appId}-future`, { + defaultValue: [] + }) as [SnapshotsType[], (value: SetStateAction) => 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, diff --git a/projects/app/src/pages/app/detail/components/WorkflowPublishHistoriesSlider.tsx b/projects/app/src/pages/app/detail/components/WorkflowPublishHistoriesSlider.tsx new file mode 100644 index 000000000..de4832e5a --- /dev/null +++ b/projects/app/src/pages/app/detail/components/WorkflowPublishHistoriesSlider.tsx @@ -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 ( + <> + onClose()} + title={ + ( + <> + + + ) as any + } + maxW={'340px'} + px={0} + showMask={false} + top={'60px'} + overflow={'unset'} + > + {currentTab === 'myEdit' ? : } + + + ); +}; + +export default React.memo(WorkflowPublishHistoriesSlider); + +const MyEdit = () => { + const { past, saveSnapshot, resetSnapshot } = useContextSelector(WorkflowContext, (v) => v); + const { t } = useTranslation(); + const { toast } = useToast(); + + return ( + + {past.length > 0 && ( + + + + )} + + {past.map((item, index) => { + return ( + { + 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' + }); + }} + > + + + {item.title} + + + ); + })} + + {t('common:common.No more data')} + + + + ); +}; + +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(undefined); + const [hoveredIndex, setHoveredIndex] = useState(undefined); + + const [isEditing, setIsEditing] = useState(false); + const { toast } = useToast(); + + return ( + + {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 ( + 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' + }); + }} + > + + + + } + > + {({ onClose }) => ( + + + + + + + {tmb?.memberName} + + + {formatTime2YMDHMS(item.time)} + + + + )} + + {editIndex !== index ? ( + <> + + + + {item.versionName || formatTime2YMDHMS(item.time)} + + + {item.isPublish && ( + + {index === firstPublishedIndex + ? t('app:app.version_current') + : t('app:app.version_past')} + + )} + + {hoveredIndex === index && ( + { + e.stopPropagation(); + setEditIndex(index); + }} + /> + )} + + ) : ( + + + )} + + ); + })} + + ); +}; diff --git a/projects/app/src/pages/app/detail/components/constants.tsx b/projects/app/src/pages/app/detail/components/constants.tsx index 4cdc9a445..4b77113cc 100644 --- a/projects/app/src/pages/app/detail/components/constants.tsx +++ b/projects/app/src/pages/app/detail/components/constants.tsx @@ -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') } }; diff --git a/projects/app/src/pages/app/detail/components/context.tsx b/projects/app/src/pages/app/detail/components/context.tsx index 694b92ab2..112dc74e5 100644 --- a/projects/app/src/pages/app/detail/components/context.tsx +++ b/projects/app/src/pages/app/detail/components/context.tsx @@ -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'; diff --git a/projects/app/src/web/core/app/api/version.ts b/projects/app/src/web/core/app/api/version.ts index 661879fc0..9e25ecd94 100644 --- a/projects/app/src/web/core/app/api/version.ts +++ b/projects/app/src/web/core/app/api/version.ts @@ -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('/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); diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 7535fcc4c..f34329886 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -512,3 +512,111 @@ export const compareWorkflow = (workflow1: WorkflowType, workflow2: WorkflowType return isEqual(node1, node2); }; + +export const compareSnapshot = ( + snapshot1: { + nodes: Node[] | undefined; + edges: Edge[] | undefined; + chatConfig: AppChatConfigType | undefined; + }, + snapshot2: { + nodes: Node[]; + edges: Edge[]; + 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); +};