perf: workflow snapshots;fix: extrat node selected (#3334)

* doc

* perf: workflow snapshots

* fix: extrat node selected

* refresh page reset snapshot
This commit is contained in:
Archer
2024-12-06 18:08:52 +08:00
committed by GitHub
parent 0c308fcf8b
commit 90d7d2a164
27 changed files with 447 additions and 524 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -7,6 +7,26 @@ toc: true
weight: 809 weight: 809
--- ---
## 新功能预览
### API 知识库
| | |
| --- | --- |
| ![alt text](/imgs/image-20.png) | ![alt text](/imgs/image-21.png) |
### HTML 渲染
| 源码模式 | 预览模式 | 全屏模式 |
| --- | --- | --- |
| ![alt text](/imgs/image-22.png) | ![alt text](/imgs/image-23.png) | ![alt text](/imgs/image-24.png) |
## 升级指南
- 更新 FastGPT 镜像 tag: v4.8.15-alpha
- 更新 FastGPT 商业版镜像 tag: v4.8.15-alpha fastgpt-pro镜像
- Sandbox 镜像,可以不更新
## 完整更新内容 ## 完整更新内容
@@ -24,4 +44,5 @@ weight: 809
12. 修复 - 分享链接点赞鉴权问题。 12. 修复 - 分享链接点赞鉴权问题。
13. 修复 - 对话页面切换自动执行应用时,会误触发非自动执行应用。 13. 修复 - 对话页面切换自动执行应用时,会误触发非自动执行应用。
14. 修复 - 语言播放鉴权问题。 14. 修复 - 语言播放鉴权问题。
15. 修复 - 插件应用知识库引用上限始终为 3000 15. 修复 - 插件应用知识库引用上限始终为 3000
16. 修复 - 工作流编辑记录存储上限,去掉本地存储,增加异常离开时,强制自动保存。

View File

@@ -17,6 +17,7 @@
"auto_execute": "Automatic execution", "auto_execute": "Automatic execution",
"auto_execute_default_prompt_placeholder": "Default questions sent when executing automatically", "auto_execute_default_prompt_placeholder": "Default questions sent when executing automatically",
"auto_execute_tip": "After turning it on, the workflow will be automatically triggered when the user enters the conversation interface. \nExecution order: 1. Dialogue starter; 2. Global variables; 3. Automatic execution.", "auto_execute_tip": "After turning it on, the workflow will be automatically triggered when the user enters the conversation interface. \nExecution order: 1. Dialogue starter; 2. Global variables; 3. Automatic execution.",
"auto_save": "Auto save",
"chat_debug": "Chat Preview", "chat_debug": "Chat Preview",
"chat_logs": "Conversation Logs", "chat_logs": "Conversation Logs",
"chat_logs_tips": "Logs will record the online, shared, and API (requires chatId) conversation records of this app.", "chat_logs_tips": "Logs will record the online, shared, and API (requires chatId) conversation records of this app.",
@@ -135,6 +136,7 @@
"type.Plugin": "Plugin", "type.Plugin": "Plugin",
"type.Simple bot": "Simple App", "type.Simple bot": "Simple App",
"type.Workflow bot": "Workflow", "type.Workflow bot": "Workflow",
"unusual_leave_auto_save": "Abnormal exit, automatic saving",
"upload_file_max_amount": "Maximum File Quantity", "upload_file_max_amount": "Maximum File Quantity",
"upload_file_max_amount_tip": "Maximum number of files uploaded in a single round of conversation", "upload_file_max_amount_tip": "Maximum number of files uploaded in a single round of conversation",
"variable.select type_desc": "You can define a global variable that does not need to be filled in by the user.\n\nThe value of this variable can come from the API interface, the Query of the shared link, or assigned through the [Variable Update] module.", "variable.select type_desc": "You can define a global variable that does not need to be filled in by the user.\n\nThe value of this variable can come from the API interface, the Query of the shared link, or assigned through the [Variable Update] module.",

View File

@@ -17,6 +17,7 @@
"auto_execute": "自动执行", "auto_execute": "自动执行",
"auto_execute_default_prompt_placeholder": "自动执行时,发送的默认问题", "auto_execute_default_prompt_placeholder": "自动执行时,发送的默认问题",
"auto_execute_tip": "开启后用户进入对话界面将自动触发工作流。执行顺序1、对话开场白2、全局变量3、自动执行。", "auto_execute_tip": "开启后用户进入对话界面将自动触发工作流。执行顺序1、对话开场白2、全局变量3、自动执行。",
"auto_save": "自动保存",
"chat_debug": "调试预览", "chat_debug": "调试预览",
"chat_logs": "对话日志", "chat_logs": "对话日志",
"chat_logs_tips": "日志会记录该应用的在线、分享和 API需填写 chatId对话记录", "chat_logs_tips": "日志会记录该应用的在线、分享和 API需填写 chatId对话记录",
@@ -135,6 +136,7 @@
"type.Plugin": "插件", "type.Plugin": "插件",
"type.Simple bot": "简易应用", "type.Simple bot": "简易应用",
"type.Workflow bot": "工作流", "type.Workflow bot": "工作流",
"unusual_leave_auto_save": "异常离开,自动保存",
"upload_file_max_amount": "最大文件数量", "upload_file_max_amount": "最大文件数量",
"upload_file_max_amount_tip": "单轮对话中最大上传文件数量", "upload_file_max_amount_tip": "单轮对话中最大上传文件数量",
"variable.select type_desc": "可以为工作流定义全局变量,常用临时缓存。赋值的方式包括:\n1. 从对话页面的 query 参数获取。\n2. 通过 API 的 variables 对象传递。\n3. 通过【变量更新】节点进行赋值。", "variable.select type_desc": "可以为工作流定义全局变量,常用临时缓存。赋值的方式包括:\n1. 从对话页面的 query 参数获取。\n2. 通过 API 的 variables 对象传递。\n3. 通过【变量更新】节点进行赋值。",

View File

@@ -17,6 +17,7 @@
"auto_execute": "自動執行", "auto_execute": "自動執行",
"auto_execute_default_prompt_placeholder": "自動執行時,發送的預設問題", "auto_execute_default_prompt_placeholder": "自動執行時,發送的預設問題",
"auto_execute_tip": "開啟後,使用者進入對話式介面將自動觸發工作流程。\n執行順序1、對話開場白2、全域變數3、自動執行。", "auto_execute_tip": "開啟後,使用者進入對話式介面將自動觸發工作流程。\n執行順序1、對話開場白2、全域變數3、自動執行。",
"auto_save": "自動儲存",
"chat_debug": "聊天預覽", "chat_debug": "聊天預覽",
"chat_logs": "對話紀錄", "chat_logs": "對話紀錄",
"chat_logs_tips": "紀錄會記錄此應用程式的線上、分享和 API需填寫 chatId對話紀錄", "chat_logs_tips": "紀錄會記錄此應用程式的線上、分享和 API需填寫 chatId對話紀錄",
@@ -135,6 +136,7 @@
"type.Plugin": "外掛", "type.Plugin": "外掛",
"type.Simple bot": "簡易應用程式", "type.Simple bot": "簡易應用程式",
"type.Workflow bot": "工作流程", "type.Workflow bot": "工作流程",
"unusual_leave_auto_save": "異常離開,自動儲存",
"upload_file_max_amount": "最大檔案數量", "upload_file_max_amount": "最大檔案數量",
"upload_file_max_amount_tip": "單輪對話中最大上傳檔案數量", "upload_file_max_amount_tip": "單輪對話中最大上傳檔案數量",
"variable.select type_desc": "可以為工作流程定義全域變數,常用於暫存。賦值的方式包括:\n1. 從對話頁面的 query 參數取得。\n2. 透過 API 的 variables 物件傳遞。\n3. 透過【變數更新】節點進行賦值。", "variable.select type_desc": "可以為工作流程定義全域變數,常用於暫存。賦值的方式包括:\n1. 從對話頁面的 query 參數取得。\n2. 透過 API 的 variables 物件傳遞。\n3. 透過【變數更新】節點進行賦值。",

View File

@@ -3,7 +3,7 @@ import { defaultAutoExecuteConfig } from '@fastgpt/global/core/app/constants';
import { AppAutoExecuteConfigType } from '@fastgpt/global/core/app/type'; import { AppAutoExecuteConfigType } from '@fastgpt/global/core/app/type';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'next-i18next';
import ChatFunctionTip from './Tip'; import ChatFunctionTip from './Tip';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import MyModal from '@fastgpt/web/components/common/MyModal'; import MyModal from '@fastgpt/web/components/common/MyModal';

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
Box, Box,
Flex, Flex,
@@ -22,19 +22,13 @@ import AppCard from '../WorkflowComponents/AppCard';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import MyModal from '@fastgpt/web/components/common/MyModal'; import MyModal from '@fastgpt/web/components/common/MyModal';
import { compareSnapshot } from '@/web/core/workflow/utils';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { useDebounceEffect } from 'ahooks';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import SaveButton from '../Workflow/components/SaveButton'; import SaveButton from '../Workflow/components/SaveButton';
import PublishHistories from '../PublishHistoriesSlider'; import PublishHistories from '../PublishHistoriesSlider';
import {
WorkflowNodeEdgeContext,
WorkflowInitContext
} from '../WorkflowComponents/context/workflowInitContext';
import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext'; import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext';
import { getAppConfigByDiff } from '@/web/core/app/diff'; import { WorkflowStatusContext } from '../WorkflowComponents/context/workflowStatusContext';
const Header = () => { const Header = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,9 +44,6 @@ const Header = () => {
onClose: onCloseBackConfirm onClose: onCloseBackConfirm
} = useDisclosure(); } = useDisclosure();
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData); const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
const flowData2StoreDataAndCheck = useContextSelector( const flowData2StoreDataAndCheck = useContextSelector(
WorkflowContext, WorkflowContext,
@@ -60,7 +51,6 @@ const Header = () => {
); );
const setWorkflowTestData = useContextSelector(WorkflowContext, (v) => v.setWorkflowTestData); const setWorkflowTestData = useContextSelector(WorkflowContext, (v) => v.setWorkflowTestData);
const past = useContextSelector(WorkflowContext, (v) => v.past); const past = useContextSelector(WorkflowContext, (v) => v.past);
const future = useContextSelector(WorkflowContext, (v) => v.future);
const setPast = useContextSelector(WorkflowContext, (v) => v.setPast); const setPast = useContextSelector(WorkflowContext, (v) => v.setPast);
const onSwitchTmpVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchTmpVersion); const onSwitchTmpVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchTmpVersion);
const onSwitchCloudVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchCloudVersion); const onSwitchCloudVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchCloudVersion);
@@ -71,40 +61,11 @@ const Header = () => {
(v) => v.setShowHistoryModal (v) => v.setShowHistoryModal
); );
const isSaved = useContextSelector(WorkflowStatusContext, (v) => v.isSaved);
const leaveSaveSign = useContextSelector(WorkflowStatusContext, (v) => v.leaveSaveSign);
const { lastAppListRouteType } = useSystemStore(); const { lastAppListRouteType } = useSystemStore();
const [isPublished, setIsPublished] = useState(false);
useDebounceEffect(
() => {
const savedSnapshot =
[...future].reverse().find((snapshot) => snapshot.isSaved) ||
past.find((snapshot) => snapshot.isSaved);
const initialState = past[past.length - 1]?.state;
const savedSnapshotState = getAppConfigByDiff(initialState, savedSnapshot?.diff);
const val = compareSnapshot(
// nodes of the saved snapshot
{
nodes: savedSnapshotState?.nodes,
edges: savedSnapshotState?.edges,
chatConfig: savedSnapshotState?.chatConfig
},
// nodes of the current canvas
{
nodes,
edges,
chatConfig: appDetail.chatConfig
}
);
setIsPublished(val);
},
[future, past, nodes, edges, appDetail.chatConfig],
{
wait: 500
}
);
const { runAsync: onClickSave, loading } = useRequest2( const { runAsync: onClickSave, loading } = useRequest2(
async ({ async ({
isPublish, isPublish,
@@ -140,16 +101,15 @@ const Header = () => {
); );
const onBack = useCallback(async () => { const onBack = useCallback(async () => {
try { leaveSaveSign.current = false;
router.push({ router.push({
pathname: '/app/list', pathname: '/app/list',
query: { query: {
parentId: appDetail.parentId, parentId: appDetail.parentId,
type: lastAppListRouteType type: lastAppListRouteType
} }
}); });
} catch (error) {} }, [appDetail.parentId, lastAppListRouteType, leaveSaveSign, router]);
}, [appDetail.parentId, lastAppListRouteType, router]);
const Render = useMemo(() => { const Render = useMemo(() => {
return ( return (
@@ -189,13 +149,13 @@ const Header = () => {
name={'common/leftArrowLight'} name={'common/leftArrowLight'}
w={6} w={6}
cursor={'pointer'} cursor={'pointer'}
onClick={isPublished ? onBack : onOpenBackConfirm} onClick={isSaved ? onBack : onOpenBackConfirm}
/> />
</Box> </Box>
{/* app info */} {/* app info */}
<Box ml={1}> <Box ml={1}>
<AppCard isPublished={isPublished} showSaveStatus={isV2Workflow} /> <AppCard isSaved={isSaved} showSaveStatus={isV2Workflow} />
</Box> </Box>
{isPc && ( {isPc && (
@@ -247,7 +207,7 @@ const Header = () => {
}, [ }, [
isPc, isPc,
currentTab, currentTab,
isPublished, isSaved,
onBack, onBack,
onOpenBackConfirm, onOpenBackConfirm,
isV2Workflow, isV2Workflow,
@@ -290,14 +250,16 @@ const Header = () => {
<Button <Button
isLoading={loading} isLoading={loading}
onClick={async () => { onClick={async () => {
await onClickSave({}); try {
onCloseBackConfirm(); await onClickSave({});
onBack(); onCloseBackConfirm();
toast({ onBack();
status: 'success', toast({
title: t('app:saved_success'), status: 'success',
position: 'top-right' title: t('app:saved_success'),
}); position: 'top-right'
});
} catch (error) {}
}} }}
> >
{t('common:common.Save_and_exit')} {t('common:common.Save_and_exit')}

View File

@@ -1,129 +1,26 @@
import React from 'react'; import React from 'react';
import { Box } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
import { useDebounceEffect, useLocalStorageState, useMount } from 'ahooks';
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
import { appWorkflow2Form } from '@fastgpt/global/core/app/utils';
import ChatTest from './ChatTest'; import ChatTest from './ChatTest';
import AppCard from './AppCard'; import AppCard from './AppCard';
import EditForm from './EditForm'; import EditForm from './EditForm';
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type'; import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
import { AppContext } from '@/pages/app/detail/components/context';
import { useContextSelector } from 'use-context-selector';
import { cardStyles } from '../constants'; import { cardStyles } from '../constants';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useTranslation } from 'next-i18next'; import { SimpleAppSnapshotType } from './useSnapshots';
import { onSaveSnapshotFnType, SimpleAppSnapshotType } from './useSnapshots';
import { getAppConfigByDiff, getAppDiffConfig } from '@/web/core/app/diff';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
const convertOldFormatHistory = (past: SimpleAppSnapshotType[]) => {
const baseState = past[past.length - 1].appForm;
return past.map((item, index) => {
if (index === past.length - 1) {
return {
title: item.title,
isSaved: item.isSaved,
state: baseState
};
}
const currentState = item.appForm;
const diff = getAppDiffConfig(baseState, currentState);
return {
title: item.title || formatTime2YMDHMS(new Date()),
isSaved: item.isSaved,
diff
};
});
};
const Edit = ({ const Edit = ({
appForm, appForm,
setAppForm, setAppForm,
past, setPast
setPast,
saveSnapshot
}: { }: {
appForm: AppSimpleEditFormType; appForm: AppSimpleEditFormType;
setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>; setAppForm: React.Dispatch<React.SetStateAction<AppSimpleEditFormType>>;
past: SimpleAppSnapshotType[];
setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void; setPast: (value: React.SetStateAction<SimpleAppSnapshotType[]>) => void;
saveSnapshot: onSaveSnapshotFnType;
}) => { }) => {
const { isPc } = useSystem(); const { isPc } = useSystem();
const { loadAllDatasets } = useDatasetStore();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const { t } = useTranslation();
// Reset old edit history to new variables
const [oldPast, setOldPast] = useLocalStorageState<SimpleAppSnapshotType[]>(
`${appDetail._id}-past-simple`,
{}
);
// Save snapshot to local
useDebounceEffect(
() => {
saveSnapshot({
appForm
});
},
[appForm],
{ wait: 500 }
);
// Init app form
useMount(() => {
// show selected dataset
loadAllDatasets();
if (appDetail.version !== 'v2') {
return setAppForm(
appWorkflow2Form({
nodes: v1Workflow2V2((appDetail.modules || []) as any)?.nodes,
chatConfig: appDetail.chatConfig
})
);
}
// Get the latest snapshot
if (past?.[0]?.diff) {
const pastState = getAppConfigByDiff(past[past.length - 1].state, past[0].diff);
return setAppForm(pastState);
} else if (oldPast && oldPast.length > 0 && oldPast?.every((item) => item.appForm)) {
// 格式化成 diff
const newPast = convertOldFormatHistory(oldPast);
setPast(newPast);
setOldPast && setOldPast([]);
return setAppForm(getAppConfigByDiff(newPast[newPast.length - 1].state, newPast[0].diff));
}
const appForm = appWorkflow2Form({
nodes: appDetail.modules,
chatConfig: appDetail.chatConfig
});
// Set the first snapshot
if (past.length === 0) {
saveSnapshot({
appForm,
title: t('app:initial_form'),
isSaved: true
});
}
setAppForm(appForm);
});
return ( return (
<Box <Box

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context'; import { AppContext } from '../context';
import FolderPath from '@/components/common/folder/Path'; import FolderPath from '@/components/common/folder/Path';
@@ -28,7 +28,7 @@ import {
} from './useSnapshots'; } from './useSnapshots';
import PublishHistories from '../PublishHistoriesSlider'; import PublishHistories from '../PublishHistoriesSlider';
import { AppVersionSchemaType } from '@fastgpt/global/core/app/version'; import { AppVersionSchemaType } from '@fastgpt/global/core/app/version';
import { getAppConfigByDiff } from '@/web/core/app/diff'; import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
const Header = ({ const Header = ({
forbiddenSaveSnapshot, forbiddenSaveSnapshot,
@@ -51,7 +51,6 @@ const Header = ({
const appId = useContextSelector(AppContext, (v) => v.appId); const appId = useContextSelector(AppContext, (v) => v.appId);
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp); const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp);
const currentTab = useContextSelector(AppContext, (v) => v.currentTab); const currentTab = useContextSelector(AppContext, (v) => v.currentTab);
const appLatestVersion = useContextSelector(AppContext, (v) => v.appLatestVersion);
const { lastAppListRouteType } = useSystemStore(); const { lastAppListRouteType } = useSystemStore();
const { allDatasets } = useDatasetStore(); const { allDatasets } = useDatasetStore();
@@ -105,19 +104,9 @@ const Header = ({
const [isShowHistories, { setTrue: setIsShowHistories, setFalse: closeHistories }] = const [isShowHistories, { setTrue: setIsShowHistories, setFalse: closeHistories }] =
useBoolean(false); useBoolean(false);
const initialAppForm = useMemo(
() =>
appWorkflow2Form({
nodes: appLatestVersion?.nodes || [],
chatConfig: appLatestVersion?.chatConfig || {}
}),
[appLatestVersion]
);
const onSwitchTmpVersion = useCallback( const onSwitchTmpVersion = useCallback(
(data: SimpleAppSnapshotType, customTitle: string) => { (data: SimpleAppSnapshotType, customTitle: string) => {
const pastState = getAppConfigByDiff(initialAppForm, data.diff); setAppForm(data.appForm);
setAppForm(pastState);
// Remove multiple "copy-" // Remove multiple "copy-"
const copyText = t('app:version_copy'); const copyText = t('app:version_copy');
@@ -125,11 +114,11 @@ const Header = ({
const title = customTitle.replace(regex, `$1`); const title = customTitle.replace(regex, `$1`);
return saveSnapshot({ return saveSnapshot({
appForm: pastState, appForm: data.appForm,
title title
}); });
}, },
[initialAppForm, saveSnapshot, setAppForm, t] [saveSnapshot, setAppForm, t]
); );
const onSwitchCloudVersion = useCallback( const onSwitchCloudVersion = useCallback(
(appVersion: AppVersionSchemaType) => { (appVersion: AppVersionSchemaType) => {
@@ -152,18 +141,31 @@ const Header = ({
); );
// Check if the workflow is published // Check if the workflow is published
const [isPublished, setIsPublished] = useState(false); const [isSaved, setIsSaved] = useState(false);
useDebounceEffect( useDebounceEffect(
() => { () => {
const savedSnapshot = past.find((snapshot) => snapshot.isSaved); const savedSnapshot = past.find((snapshot) => snapshot.isSaved);
const pastState = getAppConfigByDiff(initialAppForm, savedSnapshot?.diff); const val = compareSimpleAppSnapshot(savedSnapshot?.appForm, appForm);
const val = compareSimpleAppSnapshot(pastState, appForm); setIsSaved(val);
setIsPublished(val);
}, },
[past, allDatasets], [past, allDatasets],
{ wait: 500 } { wait: 500 }
); );
const onLeaveAutoSave = useCallback(() => {
if (isSaved) return;
try {
console.log('Leave auto save');
onClickSave({ isPublish: false, versionName: t('app:auto_save') });
} catch (error) {
console.error(error);
}
}, [isSaved, onClickSave, t]);
useBeforeunload({
tip: t('common:core.common.tip.leave page'),
callback: onLeaveAutoSave
});
return ( return (
<Box h={14}> <Box h={14}>
{!isPc && ( {!isPc && (
@@ -196,13 +198,13 @@ const Header = ({
type={'borderFill'} type={'borderFill'}
showDot showDot
colorSchema={ colorSchema={
isPublished isSaved
? publishStatusStyle.published.colorSchema ? publishStatusStyle.published.colorSchema
: publishStatusStyle.unPublish.colorSchema : publishStatusStyle.unPublish.colorSchema
} }
> >
{t( {t(
isPublished isSaved
? publishStatusStyle.published.text ? publishStatusStyle.published.text
: publishStatusStyle.unPublish.text : publishStatusStyle.unPublish.text
)} )}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { getDefaultAppForm } from '@fastgpt/global/core/app/utils'; import { appWorkflow2Form, getDefaultAppForm } from '@fastgpt/global/core/app/utils';
import Header from './Header'; import Header from './Header';
import Edit from './Edit'; import Edit from './Edit';
@@ -7,16 +7,20 @@ import { useContextSelector } from 'use-context-selector';
import { AppContext, TabEnum } from '../context'; import { AppContext, TabEnum } from '../context';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Box, Flex } from '@chakra-ui/react'; import { Box, Flex } from '@chakra-ui/react';
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useSimpleAppSnapshots } from './useSnapshots'; import { SimpleAppSnapshotType, useSimpleAppSnapshots } from './useSnapshots';
import { useDebounceEffect } from 'ahooks'; import { useDebounceEffect, useMount } from 'ahooks';
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
import { getAppConfigByDiff } from '@/web/core/app/diff';
const Logs = dynamic(() => import('../Logs/index')); const Logs = dynamic(() => import('../Logs/index'));
const PublishChannel = dynamic(() => import('../Publish')); const PublishChannel = dynamic(() => import('../Publish'));
const SimpleEdit = () => { const SimpleEdit = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { loadAllDatasets } = useDatasetStore();
const { currentTab, appDetail } = useContextSelector(AppContext, (v) => v); const { currentTab, appDetail } = useContextSelector(AppContext, (v) => v);
const { forbiddenSaveSnapshot, past, setPast, saveSnapshot } = useSimpleAppSnapshots( const { forbiddenSaveSnapshot, past, setPast, saveSnapshot } = useSimpleAppSnapshots(
appDetail._id appDetail._id
@@ -24,10 +28,86 @@ const SimpleEdit = () => {
const [appForm, setAppForm] = useState(getDefaultAppForm()); const [appForm, setAppForm] = useState(getDefaultAppForm());
useBeforeunload({ // Init app form
tip: t('common:core.common.tip.leave page') useMount(() => {
// show selected dataset
loadAllDatasets();
if (appDetail.version !== 'v2') {
return setAppForm(
appWorkflow2Form({
nodes: v1Workflow2V2((appDetail.modules || []) as any)?.nodes,
chatConfig: appDetail.chatConfig
})
);
}
// 读取旧的存储记录
const pastSnapshot = (() => {
try {
const pastSnapshot = localStorage.getItem(`${appDetail._id}-past`);
return pastSnapshot ? (JSON.parse(pastSnapshot) as SimpleAppSnapshotType[]) : [];
} catch (error) {
return [];
}
})();
const defaultState = pastSnapshot?.[pastSnapshot.length - 1]?.state;
if (pastSnapshot?.[0]?.diff && defaultState) {
setPast(
pastSnapshot
.map((item) => {
if (!item.state && !item.diff) return;
if (!item.diff) {
return {
title: t('app:initial_form'),
isSaved: true,
appForm: defaultState
};
}
const currentState = getAppConfigByDiff(defaultState, item.diff);
return {
title: item.title,
isSaved: item.isSaved,
appForm: currentState
};
})
.filter(Boolean) as SimpleAppSnapshotType[]
);
const pastState = getAppConfigByDiff(defaultState, pastSnapshot[0].diff);
localStorage.removeItem(`${appDetail._id}-past`);
return setAppForm(pastState);
}
// 无旧的记录,正常初始化
if (past.length === 0) {
const appForm = appWorkflow2Form({
nodes: appDetail.modules,
chatConfig: appDetail.chatConfig
});
saveSnapshot({
appForm,
title: t('app:initial_form'),
isSaved: true
});
setAppForm(appForm);
} else {
setAppForm(past[0].appForm);
}
}); });
// Save snapshot to local
useDebounceEffect(
() => {
saveSnapshot({
appForm
});
},
[appForm],
{ wait: 500 }
);
return ( return (
<Flex h={'100%'} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}> <Flex h={'100%'} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}>
<Header <Header
@@ -39,13 +119,7 @@ const SimpleEdit = () => {
saveSnapshot={saveSnapshot} saveSnapshot={saveSnapshot}
/> />
{currentTab === TabEnum.appEdit ? ( {currentTab === TabEnum.appEdit ? (
<Edit <Edit appForm={appForm} setAppForm={setAppForm} setPast={setPast} />
appForm={appForm}
setAppForm={setAppForm}
past={past}
setPast={setPast}
saveSnapshot={saveSnapshot}
/>
) : ( ) : (
<Box flex={'1 0 0'} h={0} mt={[4, 0]}> <Box flex={'1 0 0'} h={0} mt={[4, 0]}>
{currentTab === TabEnum.publish && <PublishChannel />} {currentTab === TabEnum.publish && <PublishChannel />}

View File

@@ -1,18 +1,17 @@
import { useLocalStorageState, useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { SetStateAction, useEffect, useRef } from 'react'; import { useRef, useState } from 'react';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type'; import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { getAppDiffConfig } from '@/web/core/app/diff';
export type SimpleAppSnapshotType = { export type SimpleAppSnapshotType = {
diff?: Record<string, any>;
title: string; title: string;
isSaved?: boolean; isSaved?: boolean;
state?: AppSimpleEditFormType; appForm: AppSimpleEditFormType;
// old format // abandon
appForm?: AppSimpleEditFormType; state?: AppSimpleEditFormType;
diff?: Record<string, any>;
}; };
export type onSaveSnapshotFnType = (props: { export type onSaveSnapshotFnType = (props: {
appForm: AppSimpleEditFormType; // Current edited app form data appForm: AppSimpleEditFormType; // Current edited app form data
@@ -57,9 +56,7 @@ export const compareSimpleAppSnapshot = (
export const useSimpleAppSnapshots = (appId: string) => { export const useSimpleAppSnapshots = (appId: string) => {
const forbiddenSaveSnapshot = useRef(false); const forbiddenSaveSnapshot = useRef(false);
const [past, setPast] = useLocalStorageState<SimpleAppSnapshotType[]>(`${appId}-past`, { const [past, setPast] = useState<SimpleAppSnapshotType[]>([]);
defaultValue: []
}) as [SimpleAppSnapshotType[], (value: SetStateAction<SimpleAppSnapshotType[]>) => void];
const saveSnapshot: onSaveSnapshotFnType = useMemoizedFn(async ({ appForm, title, isSaved }) => { const saveSnapshot: onSaveSnapshotFnType = useMemoizedFn(async ({ appForm, title, isSaved }) => {
if (forbiddenSaveSnapshot.current) { if (forbiddenSaveSnapshot.current) {
@@ -72,50 +69,28 @@ export const useSimpleAppSnapshots = (appId: string) => {
{ {
title: title || formatTime2YMDHMS(new Date()), title: title || formatTime2YMDHMS(new Date()),
isSaved, isSaved,
state: appForm appForm
} }
]); ]);
return true; return true;
} }
const lastPast = past[past.length - 1]; const pastState = past[0];
if (!lastPast?.state) return false; const isPastEqual = compareSimpleAppSnapshot(pastState?.appForm, appForm);
if (isPastEqual) return false;
// Get the diff between the current app form data and the initial state setPast((past) => [
const diff = getAppDiffConfig(lastPast.state, appForm); {
appForm,
// If the diff is the same as the previous snapshot, do not save
if (past[0].diff && isEqual(past[0].diff, diff)) return false;
setPast((past) => {
const newPast = {
diff,
title: title || formatTime2YMDHMS(new Date()), title: title || formatTime2YMDHMS(new Date()),
isSaved isSaved
}; },
...past.slice(0, 99)
]);
if (past.length >= 100) {
return [newPast, ...past.slice(0, 98), lastPast];
}
return [newPast, ...past];
});
return true; return true;
}); });
// remove other app's snapshot
useEffect(() => {
const keys = Object.keys(localStorage);
const snapshotKeys = keys.filter(
(key) => key.endsWith('-past') || key.endsWith('-past-simple')
);
snapshotKeys.forEach((key) => {
const keyAppId = key.split('-')[0];
if (keyAppId !== appId) {
localStorage.removeItem(key);
}
});
}, [appId]);
return { forbiddenSaveSnapshot, past, setPast, saveSnapshot }; return { forbiddenSaveSnapshot, past, setPast, saveSnapshot };
}; };

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
Box, Box,
Flex, Flex,
@@ -22,19 +22,13 @@ import AppCard from '../WorkflowComponents/AppCard';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import MyModal from '@fastgpt/web/components/common/MyModal'; import MyModal from '@fastgpt/web/components/common/MyModal';
import { compareSnapshot } from '@/web/core/workflow/utils';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { useDebounceEffect } from 'ahooks';
import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useSystemStore } from '@/web/common/system/useSystemStore';
import SaveButton from './components/SaveButton'; import SaveButton from './components/SaveButton';
import PublishHistories from '../PublishHistoriesSlider'; import PublishHistories from '../PublishHistoriesSlider';
import {
WorkflowNodeEdgeContext,
WorkflowInitContext
} from '../WorkflowComponents/context/workflowInitContext';
import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext'; import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext';
import { getAppConfigByDiff } from '@/web/core/app/diff'; import { WorkflowStatusContext } from '../WorkflowComponents/context/workflowStatusContext';
const Header = () => { const Header = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -54,9 +48,6 @@ const Header = () => {
onClose: onCloseBackConfirm onClose: onCloseBackConfirm
} = useDisclosure(); } = useDisclosure();
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData); const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
const flowData2StoreDataAndCheck = useContextSelector( const flowData2StoreDataAndCheck = useContextSelector(
WorkflowContext, WorkflowContext,
@@ -64,7 +55,6 @@ const Header = () => {
); );
const setWorkflowTestData = useContextSelector(WorkflowContext, (v) => v.setWorkflowTestData); const setWorkflowTestData = useContextSelector(WorkflowContext, (v) => v.setWorkflowTestData);
const past = useContextSelector(WorkflowContext, (v) => v.past); const past = useContextSelector(WorkflowContext, (v) => v.past);
const future = useContextSelector(WorkflowContext, (v) => v.future);
const setPast = useContextSelector(WorkflowContext, (v) => v.setPast); const setPast = useContextSelector(WorkflowContext, (v) => v.setPast);
const onSwitchTmpVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchTmpVersion); const onSwitchTmpVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchTmpVersion);
const onSwitchCloudVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchCloudVersion); const onSwitchCloudVersion = useContextSelector(WorkflowContext, (v) => v.onSwitchCloudVersion);
@@ -75,39 +65,11 @@ const Header = () => {
(v) => v.setShowHistoryModal (v) => v.setShowHistoryModal
); );
const isSaved = useContextSelector(WorkflowStatusContext, (v) => v.isSaved);
const leaveSaveSign = useContextSelector(WorkflowStatusContext, (v) => v.leaveSaveSign);
const { lastAppListRouteType } = useSystemStore(); const { lastAppListRouteType } = useSystemStore();
// Check if the workflow is published
const [isPublished, setIsPublished] = useState(false);
useDebounceEffect(
() => {
const savedSnapshot =
[...future].reverse().find((snapshot) => snapshot.isSaved) ||
past.find((snapshot) => snapshot.isSaved);
const initialState = past[past.length - 1]?.state;
const savedSnapshotState = getAppConfigByDiff(initialState, savedSnapshot?.diff);
const val = compareSnapshot(
{
nodes: savedSnapshotState?.nodes,
edges: savedSnapshotState?.edges,
chatConfig: savedSnapshotState?.chatConfig
},
{
nodes: nodes,
edges: edges,
chatConfig: appDetail.chatConfig
}
);
setIsPublished(val);
},
[future, past, nodes, edges, appDetail.chatConfig],
{
wait: 500
}
);
const { runAsync: onClickSave, loading } = useRequest2( const { runAsync: onClickSave, loading } = useRequest2(
async ({ async ({
isPublish, isPublish,
@@ -143,16 +105,15 @@ const Header = () => {
); );
const onBack = useCallback(async () => { const onBack = useCallback(async () => {
try { leaveSaveSign.current = false;
router.push({ router.push({
pathname: '/app/list', pathname: '/app/list',
query: { query: {
parentId: appDetail.parentId, parentId: appDetail.parentId,
type: lastAppListRouteType type: lastAppListRouteType
} }
}); });
} catch (error) {} }, [appDetail.parentId, lastAppListRouteType, leaveSaveSign, router]);
}, [appDetail.parentId, lastAppListRouteType, router]);
const Render = useMemo(() => { const Render = useMemo(() => {
return ( return (
@@ -192,13 +153,13 @@ const Header = () => {
name={'common/leftArrowLight'} name={'common/leftArrowLight'}
w={6} w={6}
cursor={'pointer'} cursor={'pointer'}
onClick={isPublished ? onBack : onOpenBackConfirm} onClick={isSaved ? onBack : onOpenBackConfirm}
/> />
</Box> </Box>
{/* app info */} {/* app info */}
<Box ml={1}> <Box ml={1}>
<AppCard isPublished={isPublished} showSaveStatus={isV2Workflow} /> <AppCard isSaved={isSaved} showSaveStatus={isV2Workflow} />
</Box> </Box>
{isPc && ( {isPc && (
@@ -250,7 +211,7 @@ const Header = () => {
}, [ }, [
isPc, isPc,
currentTab, currentTab,
isPublished, isSaved,
onBack, onBack,
onOpenBackConfirm, onOpenBackConfirm,
isV2Workflow, isV2Workflow,
@@ -294,14 +255,16 @@ const Header = () => {
<Button <Button
isLoading={loading} isLoading={loading}
onClick={async () => { onClick={async () => {
await onClickSave({}); try {
onCloseBackConfirm(); await onClickSave({});
onBack(); onCloseBackConfirm();
backSaveToast({ onBack();
status: 'success', backSaveToast({
title: t('app:saved_success'), status: 'success',
position: 'top-right' title: t('app:saved_success'),
}); position: 'top-right'
});
} catch (error) {}
}} }}
> >
{t('common:common.Save_and_exit')} {t('common:common.Save_and_exit')}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { appSystemModuleTemplates } from '@fastgpt/global/core/workflow/template/constants'; import { appSystemModuleTemplates } from '@fastgpt/global/core/workflow/template/constants';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { v1Workflow2V2 } from '@/web/core/workflow/adapt'; import { v1Workflow2V2 } from '@/web/core/workflow/adapt';
import WorkflowContextProvider, { WorkflowContext } from '../WorkflowComponents/context'; import { WorkflowContext } from '../WorkflowComponents/context';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { AppContext, TabEnum } from '../context'; import { AppContext, TabEnum } from '../context';
import { useMount } from 'ahooks'; import { useMount } from 'ahooks';
@@ -20,7 +20,9 @@ const Logs = dynamic(() => import('../Logs/index'));
const PublishChannel = dynamic(() => import('../Publish')); const PublishChannel = dynamic(() => import('../Publish'));
const WorkflowEdit = () => { const WorkflowEdit = () => {
const { appDetail, currentTab } = useContextSelector(AppContext, (e) => e); const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const currentTab = useContextSelector(AppContext, (v) => v.currentTab);
const isV2Workflow = appDetail?.version === 'v2'; const isV2Workflow = appDetail?.version === 'v2';
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { Box, Flex, HStack, useDisclosure } from '@chakra-ui/react'; import { Box, Flex, HStack, useDisclosure } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector'; import { useContextSelector } from 'use-context-selector';
import { AppContext, TabEnum } from '../context'; import { AppContext } from '../context';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar'; import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
@@ -19,18 +19,14 @@ import { useSystemStore } from '@/web/common/system/useSystemStore';
const ImportSettings = dynamic(() => import('./Flow/ImportSettings')); const ImportSettings = dynamic(() => import('./Flow/ImportSettings'));
const AppCard = ({ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved: boolean }) => {
showSaveStatus,
isPublished
}: {
showSaveStatus: boolean;
isPublished: boolean;
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { feConfigs } = useSystemStore(); const { feConfigs } = useSystemStore();
const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp, currentTab } = const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp } = useContextSelector(
useContextSelector(AppContext, (v) => v); AppContext,
(v) => v
);
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure(); const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure();
@@ -182,16 +178,12 @@ const AppCard = ({
showDot showDot
bg={'transparent'} bg={'transparent'}
colorSchema={ colorSchema={
isPublished isSaved
? publishStatusStyle.published.colorSchema ? publishStatusStyle.published.colorSchema
: publishStatusStyle.unPublish.colorSchema : publishStatusStyle.unPublish.colorSchema
} }
> >
{t( {t(isSaved ? publishStatusStyle.published.text : publishStatusStyle.unPublish.text)}
isPublished
? publishStatusStyle.published.text
: publishStatusStyle.unPublish.text
)}
</MyTag> </MyTag>
</Flex> </Flex>
)} )}
@@ -205,7 +197,7 @@ const AppCard = ({
appDetail.avatar, appDetail.avatar,
appDetail.name, appDetail.name,
isOpenImport, isOpenImport,
isPublished, isSaved,
onCloseImport, onCloseImport,
showSaveStatus, showSaveStatus,
t t

View File

@@ -704,7 +704,7 @@ export const useWorkflow = () => {
chatConfig: appDetail.chatConfig chatConfig: appDetail.chatConfig
}); });
}, },
[nodes, edges, appDetail.chatConfig], [nodes, edges, appDetail.chatConfig, pushPastSnapshot],
{ wait: 500 } { wait: 500 }
); );

View File

@@ -36,7 +36,7 @@ import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../context'; import { WorkflowContext } from '../../../context';
import MyIconButton from '@fastgpt/web/components/common/Icon/button'; import MyIconButton from '@fastgpt/web/components/common/Icon/button';
const NodeExtract = ({ data }: NodeProps<FlowNodeItemType>) => { const NodeExtract = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { inputs, outputs, nodeId } = data; const { inputs, outputs, nodeId } = data;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -143,7 +143,7 @@ const NodeExtract = ({ data }: NodeProps<FlowNodeItemType>) => {
); );
return ( return (
<NodeCard minW={'400px'} {...data}> <NodeCard minW={'400px'} selected={selected} {...data}>
{isTool && ( {isTool && (
<> <>
<Container> <Container>

View File

@@ -1,6 +1,7 @@
import { postWorkflowDebug } from '@/web/core/workflow/api'; import { postWorkflowDebug } from '@/web/core/workflow/api';
import { import {
checkWorkflowNodeAndConnection, checkWorkflowNodeAndConnection,
compareSnapshot,
simplifyWorkflowNodes, simplifyWorkflowNodes,
storeEdgesRenderEdge, storeEdgesRenderEdge,
storeNode2FlowNode storeNode2FlowNode
@@ -15,16 +16,8 @@ import { RuntimeEdgeItemType, StoreEdgeItemType } from '@fastgpt/global/core/wor
import { FlowNodeChangeProps } from '@fastgpt/global/core/workflow/type/fe'; import { FlowNodeChangeProps } from '@fastgpt/global/core/workflow/type/fe';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { useToast } from '@fastgpt/web/hooks/useToast'; import { useToast } from '@fastgpt/web/hooks/useToast';
import { useLocalStorageState, useMemoizedFn, useUpdateEffect } from 'ahooks'; import { useMemoizedFn, useUpdateEffect } from 'ahooks';
import React, { import React, { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react';
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { Edge, Node, OnConnectStartParams, ReactFlowProvider, useReactFlow } from 'reactflow'; import { Edge, Node, OnConnectStartParams, ReactFlowProvider, useReactFlow } from 'reactflow';
import { createContext, useContextSelector } from 'use-context-selector'; import { createContext, useContextSelector } from 'use-context-selector';
import { defaultRunningStatus } from '../constants'; import { defaultRunningStatus } from '../constants';
@@ -37,11 +30,12 @@ import { useDisclosure } from '@chakra-ui/react';
import { uiWorkflow2StoreWorkflow } from '../utils'; import { uiWorkflow2StoreWorkflow } from '../utils';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { formatTime2YMDHMS, formatTime2YMDHMW } from '@fastgpt/global/common/string/time'; import { formatTime2YMDHMS, formatTime2YMDHMW } from '@fastgpt/global/common/string/time';
import { cloneDeep, isEqual } from 'lodash'; import { cloneDeep } from 'lodash';
import { AppVersionSchemaType } from '@fastgpt/global/core/app/version'; import { AppVersionSchemaType } from '@fastgpt/global/core/app/version';
import WorkflowInitContextProvider, { WorkflowNodeEdgeContext } from './workflowInitContext'; import WorkflowInitContextProvider, { WorkflowNodeEdgeContext } from './workflowInitContext';
import WorkflowEventContextProvider from './workflowEventContext'; import WorkflowEventContextProvider from './workflowEventContext';
import { getAppConfigByDiff, getAppDiffConfig } from '@/web/core/app/diff'; import { getAppConfigByDiff } from '@/web/core/app/diff';
import WorkflowStatusContextProvider from './workflowStatusContext';
/* /*
Context Context
@@ -61,30 +55,28 @@ export const ReactFlowCustomProvider = ({
<ReactFlowProvider> <ReactFlowProvider>
<WorkflowInitContextProvider> <WorkflowInitContextProvider>
<WorkflowContextProvider basicNodeTemplates={templates}> <WorkflowContextProvider basicNodeTemplates={templates}>
<WorkflowEventContextProvider>{children}</WorkflowEventContextProvider> <WorkflowEventContextProvider>
<WorkflowStatusContextProvider>{children}</WorkflowStatusContextProvider>
</WorkflowEventContextProvider>
</WorkflowContextProvider> </WorkflowContextProvider>
</WorkflowInitContextProvider> </WorkflowInitContextProvider>
</ReactFlowProvider> </ReactFlowProvider>
); );
}; };
export type WorkflowSnapshotsType = {
diff?: any;
title: string;
isSaved?: boolean;
state?: WorkflowStateType;
// old format
nodes?: Node[];
edges?: Edge[];
chatConfig?: AppChatConfigType;
};
export type WorkflowStateType = { export type WorkflowStateType = {
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
chatConfig: AppChatConfigType; chatConfig: AppChatConfigType;
}; };
export type WorkflowSnapshotsType = WorkflowStateType & {
title: string;
isSaved?: boolean;
// abandon
state?: WorkflowStateType;
diff?: any;
};
type WorkflowContextType = { type WorkflowContextType = {
appId?: string; appId?: string;
@@ -753,12 +745,8 @@ const WorkflowContextProvider = ({
/* snapshots */ /* snapshots */
const forbiddenSaveSnapshot = useRef(false); const forbiddenSaveSnapshot = useRef(false);
const [past, setPast] = useLocalStorageState<WorkflowSnapshotsType[]>(`${appId}-past`, { const [past, setPast] = useState<WorkflowSnapshotsType[]>([]);
defaultValue: [] const [future, setFuture] = useState<WorkflowSnapshotsType[]>([]);
}) as [WorkflowSnapshotsType[], (value: SetStateAction<WorkflowSnapshotsType[]>) => void];
const [future, setFuture] = useLocalStorageState<WorkflowSnapshotsType[]>(`${appId}-future`, {
defaultValue: []
}) as [WorkflowSnapshotsType[], (value: SetStateAction<WorkflowSnapshotsType[]>) => void];
const resetSnapshot = useMemoizedFn((state: WorkflowStateType) => { const resetSnapshot = useMemoizedFn((state: WorkflowStateType) => {
setNodes(state.nodes); setNodes(state.nodes);
@@ -777,33 +765,32 @@ const WorkflowContextProvider = ({
return false; return false;
} }
// Get initial state const isPastEqual = compareSnapshot(
const lastSnapshot = past[past.length - 1]; {
if (!lastSnapshot?.state) return false; nodes: pastNodes,
edges: pastEdges,
chatConfig: chatConfig
},
{
nodes: past[0]?.nodes,
edges: past[0]?.edges,
chatConfig: past[0]?.chatConfig
}
);
// Create current state object if (isPastEqual) return false;
const newState = {
nodes: simplifyWorkflowNodes(pastNodes),
edges: pastEdges,
chatConfig
};
// Calculate diff from initial state
const diff = getAppDiffConfig(lastSnapshot.state, newState);
if (past[0].diff && isEqual(past[0].diff, diff)) return false;
setFuture([]); setFuture([]);
setPast((past) => { setPast((past) => [
const newPast = { {
diff, nodes: pastNodes,
edges: pastEdges,
title: customTitle || formatTime2YMDHMS(new Date()), title: customTitle || formatTime2YMDHMS(new Date()),
chatConfig,
isSaved isSaved
}; },
if (past.length >= 100) { ...past.slice(0, 99)
return [newPast, ...past.slice(0, 98), lastSnapshot]; ]);
}
return [newPast, ...past];
});
return true; return true;
} }
@@ -814,14 +801,13 @@ const WorkflowContextProvider = ({
const copyText = t('app:version_copy'); const copyText = t('app:version_copy');
const regex = new RegExp(`(${copyText}-)\\1+`, 'g'); const regex = new RegExp(`(${copyText}-)\\1+`, 'g');
const title = customTitle.replace(regex, `$1`); const title = customTitle.replace(regex, `$1`);
const pastState = getAppConfigByDiff(past[past.length - 1].state, params.diff);
resetSnapshot(pastState); resetSnapshot(params);
return pushPastSnapshot({ return pushPastSnapshot({
pastNodes: pastState.nodes, pastNodes: params.nodes,
pastEdges: pastState.edges, pastEdges: params.edges,
chatConfig: pastState.chatConfig, chatConfig: params.chatConfig,
customTitle: title customTitle: title
}); });
}); });
@@ -844,38 +830,30 @@ const WorkflowContextProvider = ({
}); });
const undo = useMemoizedFn(() => { const undo = useMemoizedFn(() => {
if (past[1]) { if (past.length > 1) {
setFuture((future) => [past[0], ...future]); forbiddenSaveSnapshot.current = true;
const firstPast = past[0];
resetSnapshot(firstPast);
setFuture((future) => [firstPast, ...future]);
setPast((past) => past.slice(1)); setPast((past) => past.slice(1));
const pastState = getAppConfigByDiff(past[past.length - 1].state, past[1].diff);
resetSnapshot(pastState);
} }
}); });
const redo = useMemoizedFn(() => { const redo = useMemoizedFn(() => {
if (!future[0]) return; if (!future[0]) return;
const futureState = getAppConfigByDiff(past[past.length - 1].state, future[0].diff); const futureState = future[0];
if (futureState) { if (futureState) {
setPast((past) => [future[0], ...past]); forbiddenSaveSnapshot.current = true;
setPast((past) => [futureState, ...past]);
setFuture((future) => future.slice(1)); setFuture((future) => future.slice(1));
resetSnapshot(futureState); resetSnapshot(futureState);
} }
}); });
// 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 initData = useCallback( const initData = useCallback(
async ( async (
e: { e: {
@@ -888,18 +866,51 @@ const WorkflowContextProvider = ({
const nodes = e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || []; const nodes = e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || [];
const edges = e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []; const edges = e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || [];
const initialState = { // Get storage snapshot兼容旧版正在编辑的用户刷新后会把 local 数据存到内存并删除
nodes: simplifyWorkflowNodes(nodes), const pastSnapshot = (() => {
edges, try {
chatConfig: e.chatConfig || appDetail.chatConfig const pastSnapshot = localStorage.getItem(`${appId}-past`);
}; return pastSnapshot ? (JSON.parse(pastSnapshot) as WorkflowSnapshotsType[]) : [];
} catch (error) {
return [];
}
})();
if (isInit && pastSnapshot.length > 0) {
const defaultState = pastSnapshot[pastSnapshot.length - 1].state;
if (isInit && past.length > 0) { if (pastSnapshot[0].diff && defaultState) {
// new format // 设置旧的历史记录
if (past[0].diff && past[past.length - 1].state) { setPast(
pastSnapshot
.map((item) => {
if (item.state) {
return {
title: t(`app:app.version_initial`),
isSaved: item.isSaved,
nodes: item.state.nodes,
edges: item.state.edges,
chatConfig: item.state.chatConfig
};
}
if (item.diff) {
const currentState = getAppConfigByDiff(defaultState, item.diff);
return {
title: item.title,
isSaved: item.isSaved,
nodes: currentState.nodes,
edges: currentState.edges,
chatConfig: currentState.chatConfig
};
}
return undefined;
})
.filter(Boolean) as WorkflowSnapshotsType[]
);
// 设置当前版本
const targetState = getAppConfigByDiff( const targetState = getAppConfigByDiff(
past[past.length - 1].state, pastSnapshot[pastSnapshot.length - 1].state,
past[0].diff pastSnapshot[0].diff
) as WorkflowStateType; ) as WorkflowStateType;
setNodes(targetState.nodes); setNodes(targetState.nodes);
@@ -908,48 +919,41 @@ const WorkflowContextProvider = ({
...state, ...state,
chatConfig: targetState.chatConfig chatConfig: targetState.chatConfig
})); }));
return;
}
// 适配旧的编辑记录4.8.15去除) localStorage.removeItem(`${appId}-past`);
if (past.every((item) => item.nodes)) {
const newPast = convertOldFormatHistory(past);
setPast(newPast);
const latestState = getAppConfigByDiff(
newPast[newPast.length - 1].state,
newPast[0].diff
) as WorkflowStateType;
setNodes(latestState.nodes);
setEdges(latestState.edges);
setAppDetail((state) => ({
...state,
chatConfig: latestState.chatConfig
}));
return; return;
} }
} }
setNodes(nodes); // 有历史记录,直接用历史记录覆盖
setEdges(edges); if (isInit && past.length > 0) {
if (e.chatConfig) { const firstPast = past[0];
setAppDetail((state) => ({ ...state, chatConfig: e.chatConfig as AppChatConfigType })); setNodes(firstPast.nodes);
setEdges(firstPast.edges);
setAppDetail((state) => ({ ...state, chatConfig: firstPast.chatConfig }));
return;
} }
// 初始化一个历史记录
if (isInit && past.length === 0) { if (isInit && past.length === 0) {
setPast([ setPast([
{ {
title: t(`app:app.version_initial`), title: t(`app:app.version_initial`),
isSaved: true, isSaved: true,
state: initialState nodes: simplifyWorkflowNodes(nodes),
edges,
chatConfig: e.chatConfig || appDetail.chatConfig
} }
]); ]);
forbiddenSaveSnapshot.current = true; }
// Init memory data
setNodes(nodes);
setEdges(edges);
if (e.chatConfig) {
setAppDetail((state) => ({ ...state, chatConfig: e.chatConfig as AppChatConfigType }));
} }
}, },
[appDetail.chatConfig, past, setAppDetail, setEdges, setNodes, setPast, t] [appDetail.chatConfig, appId, past, setAppDetail, setEdges, setNodes, t]
); );
const value = useMemo( const value = useMemo(
@@ -1035,36 +1039,3 @@ const WorkflowContextProvider = ({
); );
}; };
export default React.memo(WorkflowContextProvider); export default React.memo(WorkflowContextProvider);
// Convert old history format to new format
const convertOldFormatHistory = (past: WorkflowSnapshotsType[]) => {
const baseState = {
nodes: past[past.length - 1].state?.nodes || [],
edges: past[past.length - 1].state?.edges || [],
chatConfig: past[past.length - 1].state?.chatConfig || {}
};
return past.map((item, index) => {
if (index === past.length - 1) {
return {
title: item.title,
isSaved: item.isSaved,
state: baseState
};
}
const currentState = {
nodes: item.nodes || [],
edges: item.edges || [],
chatConfig: item.chatConfig || {}
};
const diff = getAppDiffConfig(baseState, currentState);
return {
title: item.title || formatTime2YMDHMS(new Date()),
isSaved: item.isSaved,
diff
};
});
};

View File

@@ -1,42 +1,9 @@
import { createContext } from 'use-context-selector'; import { createContext } from 'use-context-selector';
import { postWorkflowDebug } from '@/web/core/workflow/api'; import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import {
checkWorkflowNodeAndConnection, import { useMemoizedFn } from 'ahooks';
compareSnapshot, import React, { Dispatch, SetStateAction, ReactNode, useEffect, useMemo } from 'react';
storeEdgesRenderEdge, import { Edge, EdgeChange, Node, NodeChange, useEdgesState, useNodesState } from 'reactflow';
storeNode2FlowNode
} from '@/web/core/workflow/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
import { FlowNodeItemType, StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import type { FlowNodeTemplateType } from '@fastgpt/global/core/workflow/type/node';
import { RuntimeEdgeItemType, StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
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 { useDebounceEffect, useLocalStorageState, useMemoizedFn, useUpdateEffect } from 'ahooks';
import React, {
Dispatch,
SetStateAction,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import {
Edge,
EdgeChange,
Node,
NodeChange,
OnConnectStartParams,
useEdgesState,
useNodesState,
useReactFlow
} from 'reactflow';
type OnChange<ChangesType> = (changes: ChangesType[]) => void; type OnChange<ChangesType> = (changes: ChangesType[]) => void;

View File

@@ -0,0 +1,89 @@
import { useDebounceEffect } from 'ahooks';
import React, { ReactNode, useMemo, useRef, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import { WorkflowInitContext, WorkflowNodeEdgeContext } from './workflowInitContext';
import { WorkflowContext } from '.';
import { AppContext } from '../../context';
import { compareSnapshot } from '@/web/core/workflow/utils';
import { useBeforeunload } from '@fastgpt/web/hooks/useBeforeunload';
import { useTranslation } from 'next-i18next';
type WorkflowStatusContextType = {
isSaved: boolean;
leaveSaveSign: React.MutableRefObject<boolean>;
};
export const WorkflowStatusContext = createContext<WorkflowStatusContextType>({
isSaved: false,
// @ts-ignore
leaveSaveSign: undefined
});
const WorkflowStatusContextProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation();
const nodes = useContextSelector(WorkflowInitContext, (v) => v.nodes);
const edges = useContextSelector(WorkflowNodeEdgeContext, (v) => v.edges);
const past = useContextSelector(WorkflowContext, (v) => v.past);
const future = useContextSelector(WorkflowContext, (v) => v.future);
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const [isSaved, setIsPublished] = useState(false);
useDebounceEffect(
() => {
const savedSnapshot =
[...future].reverse().find((snapshot) => snapshot.isSaved) ||
past.find((snapshot) => snapshot.isSaved);
const val = compareSnapshot(
{
nodes: savedSnapshot?.nodes,
edges: savedSnapshot?.edges,
chatConfig: savedSnapshot?.chatConfig
},
{
nodes,
edges,
chatConfig: appDetail.chatConfig
}
);
setIsPublished(val);
},
[future, past, nodes, edges, appDetail.chatConfig],
{
wait: 500
}
);
const leaveSaveSign = useRef(true);
// Lead check before unload
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
const onSaveApp = useContextSelector(AppContext, (v) => v.onSaveApp);
useBeforeunload({
tip: t('common:core.common.tip.leave page'),
callback: async () => {
if (isSaved || !leaveSaveSign.current) return;
console.log('Leave auto save');
const data = flowData2StoreData();
if (!data) return;
await onSaveApp({
...data,
isPublish: false,
versionName: t('app:unusual_leave_auto_save'),
chatConfig: appDetail.chatConfig
});
}
});
const contextValue = useMemo(() => {
return {
isSaved,
leaveSaveSign
};
}, [isSaved]);
return (
<WorkflowStatusContext.Provider value={contextValue}>{children}</WorkflowStatusContext.Provider>
);
};
export default WorkflowStatusContextProvider;

View File

@@ -8,7 +8,7 @@ import { DatasetPageContext } from '@/web/core/dataset/context/datasetPageContex
import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getApiDatasetFileList, getApiDatasetFileListExistId } from '@/web/core/dataset/api'; import { getApiDatasetFileList, getApiDatasetFileListExistId } from '@/web/core/dataset/api';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'next-i18next';
import { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type'; import { ParentTreePathItemType } from '@fastgpt/global/common/parentFolder/type';
import FolderPath from '@/components/common/folder/Path'; import FolderPath from '@/components/common/folder/Path';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils'; import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';

View File

@@ -3,7 +3,7 @@ import { Box, Flex, HStack, ModalBody } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar'; import Avatar from '@fastgpt/web/components/common/Avatar';
import MyBox from '@fastgpt/web/components/common/MyBox'; import MyBox from '@fastgpt/web/components/common/MyBox';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import MyModal from '@fastgpt/web/components/common/MyModal'; import MyModal from '@fastgpt/web/components/common/MyModal';
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';

View File

@@ -6,7 +6,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import PluginCard from './components/PluginCard'; import PluginCard from './components/PluginCard';
import { i18nT } from '@fastgpt/web/i18n/utils'; import { i18nT } from '@fastgpt/web/i18n/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import MyIcon from '@fastgpt/web/components/common/Icon'; import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useSystem } from '@fastgpt/web/hooks/useSystem';

View File

@@ -550,12 +550,12 @@ export const getLatestNodeTemplate = (
export const compareSnapshot = ( export const compareSnapshot = (
snapshot1: { snapshot1: {
nodes?: Node[]; nodes?: Node[];
edges: Edge<any>[] | undefined; edges?: Edge<any>[] | undefined;
chatConfig?: AppChatConfigType; chatConfig?: AppChatConfigType;
}, },
snapshot2: { snapshot2: {
nodes?: Node[]; nodes?: Node[];
edges: Edge<any>[]; edges?: Edge<any>[];
chatConfig?: AppChatConfigType; chatConfig?: AppChatConfigType;
} }
) => { ) => {
@@ -563,6 +563,8 @@ export const compareSnapshot = (
const clone2 = cloneDeep(snapshot2); const clone2 = cloneDeep(snapshot2);
if (!clone1.nodes || !clone2.nodes) return false; if (!clone1.nodes || !clone2.nodes) return false;
if (!clone1.edges || !clone2.edges) return false;
const formatEdge = (edges: Edge[] | undefined) => { const formatEdge = (edges: Edge[] | undefined) => {
if (!edges) return []; if (!edges) return [];
return edges.map((edge) => ({ return edges.map((edge) => ({