From 9b2c3b242aab02532e525e59354d40bd4103c7c6 Mon Sep 17 00:00:00 2001 From: heheer Date: Thu, 21 Nov 2024 13:12:42 +0800 Subject: [PATCH] refactor: snapshot store to diff (#3155) * refactor: snapshot store to diff * change initial state position * fix old snapshot format * encapsulate json diff --- pnpm-lock.yaml | 121 +++++++++- projects/app/package.json | 1 + .../app/detail/components/Plugin/Header.tsx | 16 +- .../app/detail/components/SimpleApp/Edit.tsx | 16 +- .../detail/components/SimpleApp/Header.tsx | 26 ++- .../components/SimpleApp/useSnapshots.tsx | 30 ++- .../app/detail/components/Workflow/Header.tsx | 12 +- .../Flow/components/ContextMenu.tsx | 1 - .../WorkflowComponents/context/index.tsx | 207 +++++++++++++----- projects/app/src/web/core/app/diff.ts | 20 ++ projects/app/src/web/core/workflow/utils.ts | 11 + 11 files changed, 362 insertions(+), 99 deletions(-) create mode 100644 projects/app/src/web/core/app/diff.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b95fc1dc3023..25297d3a1161 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 13.3.0 next-i18next: specifier: 15.3.0 - version: 15.3.0(i18next@23.11.5)(next@14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.3.0(i18next@23.11.5)(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) prettier: specifier: 3.2.4 version: 3.2.4 @@ -61,7 +61,7 @@ importers: version: 4.0.2 next: specifier: 14.2.5 - version: 14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) + version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) openai: specifier: 4.61.0 version: 4.61.0(encoding@0.1.13) @@ -201,7 +201,7 @@ importers: version: 1.4.5-lts.1 next: specifier: 14.2.5 - version: 14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) + version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) nextjs-cors: specifier: ^2.2.0 version: 2.2.0(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8)) @@ -277,7 +277,7 @@ importers: version: 2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1) '@chakra-ui/next-js': specifier: 2.1.5 - version: 2.1.5(@chakra-ui/react@2.8.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react@18.3.1) + version: 2.1.5(@chakra-ui/react@2.8.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react@18.3.1) '@chakra-ui/react': specifier: 2.8.1 version: 2.8.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -340,7 +340,7 @@ importers: version: 4.17.21 next-i18next: specifier: 15.3.0 - version: 15.3.0(i18next@23.11.5)(next@14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 15.3.0(i18next@23.11.5)(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) papaparse: specifier: ^5.4.1 version: 5.4.1 @@ -486,6 +486,9 @@ importers: json5: specifier: ^2.2.3 version: 2.2.3 + jsondiffpatch: + specifier: ^0.6.0 + version: 0.6.0 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -700,7 +703,7 @@ importers: version: 6.3.4 ts-jest: specifier: ^29.1.0 - version: 29.2.2(@babel/core@7.24.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.9))(jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0))(typescript@5.5.3) + version: 29.2.2(@babel/core@7.24.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.9))(jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)))(typescript@5.5.3) ts-loader: specifier: ^9.4.3 version: 9.5.1(typescript@5.5.3)(webpack@5.92.1) @@ -3177,8 +3180,8 @@ packages: '@tanstack/react-query@4.36.1': resolution: {integrity: sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: 18.3.1 + react-dom: 18.3.1 react-native: '*' peerDependenciesMeta: react-dom: @@ -3331,6 +3334,9 @@ packages: '@types/decompress@4.2.7': resolution: {integrity: sha512-9z+8yjKr5Wn73Pt17/ldnmQToaFHZxK0N1GHysuk/JIPT8RIdQeoInM01wWPgypRcvb6VH1drjuFpQ4zmY437g==} + '@types/diff-match-patch@1.0.36': + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -4848,6 +4854,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5139,6 +5148,7 @@ packages: eslint@8.56.0: resolution: {integrity: sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -6308,6 +6318,11 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} @@ -10462,6 +10477,14 @@ snapshots: next: 14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) react: 18.3.1 + '@chakra-ui/next-js@2.1.5(@chakra-ui/react@2.8.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react@18.3.1)': + dependencies: + '@chakra-ui/react': 2.8.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(framer-motion@9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@emotion/cache': 11.11.0 + '@emotion/react': 11.11.1(@types/react@18.3.1)(react@18.3.1) + next: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) + react: 18.3.1 + '@chakra-ui/number-input@2.1.1(@chakra-ui/system@2.6.1(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@emotion/styled@11.11.0(@emotion/react@11.11.1(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: '@chakra-ui/counter': 2.1.0(react@18.3.1) @@ -12393,6 +12416,8 @@ snapshots: dependencies: '@types/node': 22.7.8 + '@types/diff-match-patch@1.0.36': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 8.56.10 @@ -13203,7 +13228,7 @@ snapshots: axios@1.7.7: dependencies: - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 form-data: 4.0.1 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -14182,6 +14207,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + diff-match-patch@1.0.5: {} + diff-sequences@29.6.3: {} diff@4.0.2: {} @@ -14982,6 +15009,8 @@ snapshots: follow-redirects@1.15.6: {} + follow-redirects@1.15.9: {} + follow-redirects@1.15.9(debug@4.3.4): optionalDependencies: debug: 4.3.4 @@ -15044,7 +15073,7 @@ snapshots: dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tslib: 2.7.0 + tslib: 2.8.0 optionalDependencies: '@emotion/is-prop-valid': 0.8.8 @@ -16141,6 +16170,12 @@ snapshots: jsonc-parser@3.3.1: {} + jsondiffpatch@0.6.0: + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.3.0 + diff-match-patch: 1.0.5 + jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -17323,6 +17358,18 @@ snapshots: react: 18.3.1 react-i18next: 14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next-i18next@15.3.0(i18next@23.11.5)(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-i18next@14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.24.8 + '@types/hoist-non-react-statics': 3.3.5 + core-js: 3.37.1 + hoist-non-react-statics: 3.3.2 + i18next: 23.11.5 + i18next-fs-backend: 2.3.1 + next: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) + react: 18.3.1 + react-i18next: 14.1.2(i18next@23.11.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next@14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8): dependencies: '@next/env': 14.2.5 @@ -17349,10 +17396,36 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8): + dependencies: + '@next/env': 14.2.5 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001669 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.5 + '@next/swc-darwin-x64': 14.2.5 + '@next/swc-linux-arm64-gnu': 14.2.5 + '@next/swc-linux-arm64-musl': 14.2.5 + '@next/swc-linux-x64-gnu': 14.2.5 + '@next/swc-linux-x64-musl': 14.2.5 + '@next/swc-win32-arm64-msvc': 14.2.5 + '@next/swc-win32-ia32-msvc': 14.2.5 + '@next/swc-win32-x64-msvc': 14.2.5 + sass: 1.77.8 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + nextjs-cors@2.2.0(next@14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8)): dependencies: cors: 2.8.5 - next: 14.2.5(@babel/core@7.24.9)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) + next: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) nextjs-node-loader@1.1.5(webpack@5.92.1): dependencies: @@ -18410,7 +18483,7 @@ snapshots: dependencies: chokidar: 3.6.0 immutable: 4.3.6 - source-map-js: 1.2.0 + source-map-js: 1.2.1 sax@1.4.1: {} @@ -18755,6 +18828,11 @@ snapshots: '@babel/core': 7.24.9 babel-plugin-macros: 3.1.0 + styled-jsx@5.1.1(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + stylis@4.2.0: {} stylis@4.3.2: {} @@ -18956,6 +19034,25 @@ snapshots: ts-dedent@2.2.0: {} + ts-jest@29.2.2(@babel/core@7.24.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.9))(jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)))(typescript@5.5.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.14.11)(typescript@5.5.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.6.3 + typescript: 5.5.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.24.9 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.9) + ts-jest@29.2.2(@babel/core@7.24.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.9))(jest@29.7.0(@types/node@20.14.11)(babel-plugin-macros@3.1.0))(typescript@5.5.3): dependencies: bs-logger: 0.2.6 diff --git a/projects/app/package.json b/projects/app/package.json index 344763d75810..5534e540889f 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -42,6 +42,7 @@ "jest": "^29.5.0", "js-yaml": "^4.1.0", "json5": "^2.2.3", + "jsondiffpatch": "^0.6.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "mermaid": "^10.2.3", 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 8df93e258906..edee7c1c175d 100644 --- a/projects/app/src/pages/app/detail/components/Plugin/Header.tsx +++ b/projects/app/src/pages/app/detail/components/Plugin/Header.tsx @@ -13,7 +13,11 @@ import { useTranslation } from 'next-i18next'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { useContextSelector } from 'use-context-selector'; -import { WorkflowContext, WorkflowSnapshotsType } from '../WorkflowComponents/context'; +import { + WorkflowContext, + WorkflowSnapshotsType, + WorkflowStateType +} from '../WorkflowComponents/context'; import { AppContext, TabEnum } from '../context'; import RouteTab from '../RouteTab'; import { useRouter } from 'next/router'; @@ -34,6 +38,7 @@ import { WorkflowInitContext } from '../WorkflowComponents/context/workflowInitContext'; import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext'; +import { applyDiff } from '@/web/core/app/diff'; const Header = () => { const { t } = useTranslation(); @@ -76,11 +81,14 @@ const Header = () => { [...future].reverse().find((snapshot) => snapshot.isSaved) || past.find((snapshot) => snapshot.isSaved); + const initialState = past[past.length - 1]?.state; + const savedSnapshotState = applyDiff(initialState, savedSnapshot?.diff); + const val = compareSnapshot( { - nodes: savedSnapshot?.nodes, - edges: savedSnapshot?.edges, - chatConfig: savedSnapshot?.chatConfig + nodes: savedSnapshotState?.nodes, + edges: savedSnapshotState?.edges, + chatConfig: savedSnapshotState?.chatConfig }, { nodes: nodes, diff --git a/projects/app/src/pages/app/detail/components/SimpleApp/Edit.tsx b/projects/app/src/pages/app/detail/components/SimpleApp/Edit.tsx index 68af2e8960ef..df06c48112c5 100644 --- a/projects/app/src/pages/app/detail/components/SimpleApp/Edit.tsx +++ b/projects/app/src/pages/app/detail/components/SimpleApp/Edit.tsx @@ -17,6 +17,7 @@ import styles from './styles.module.scss'; import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useTranslation } from 'next-i18next'; import { onSaveSnapshotFnType, SimpleAppSnapshotType } from './useSnapshots'; +import { applyDiff } from '@/web/core/app/diff'; const Edit = ({ appForm, @@ -39,16 +40,19 @@ const Edit = ({ // show selected dataset loadAllDatasets(); - // Get the latest snapshot - if (past?.[0]?.appForm) { - return setAppForm(past[0].appForm); - } - const appForm = appWorkflow2Form({ nodes: appDetail.modules, chatConfig: appDetail.chatConfig }); + // Get the latest snapshot + if (past?.[0]?.diff) { + const pastState = applyDiff(past[past.length - 1].state, past[0].diff); + + return setAppForm(pastState); + } + + setAppForm(appForm); // Set the first snapshot if (past.length === 0) { saveSnapshot({ @@ -58,8 +62,6 @@ const Edit = ({ }); } - setAppForm(appForm); - if (appDetail.version !== 'v2') { setAppForm( appWorkflow2Form({ diff --git a/projects/app/src/pages/app/detail/components/SimpleApp/Header.tsx b/projects/app/src/pages/app/detail/components/SimpleApp/Header.tsx index c52f4d5ffb94..d0f988657dfc 100644 --- a/projects/app/src/pages/app/detail/components/SimpleApp/Header.tsx +++ b/projects/app/src/pages/app/detail/components/SimpleApp/Header.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useContextSelector } from 'use-context-selector'; import { AppContext } from '../context'; import FolderPath from '@/components/common/folder/Path'; @@ -29,6 +29,7 @@ import { } from './useSnapshots'; import PublishHistories from '../PublishHistoriesSlider'; import { AppVersionSchemaType } from '@fastgpt/global/core/app/version'; +import { applyDiff } from '@/web/core/app/diff'; const Header = ({ forbiddenSaveSnapshot, @@ -48,9 +49,20 @@ const Header = ({ const { t } = useTranslation(); const { isPc } = useSystem(); const router = useRouter(); - const { appId, onSaveApp, currentTab } = useContextSelector(AppContext, (v) => v); + const { appId, onSaveApp, currentTab, appLatestVersion } = useContextSelector( + AppContext, + (v) => v + ); const { lastAppListRouteType } = useSystemStore(); const { allDatasets } = useDatasetStore(); + const initialAppForm = useMemo( + () => + appWorkflow2Form({ + nodes: appLatestVersion?.nodes || [], + chatConfig: appLatestVersion?.chatConfig || {} + }), + [appLatestVersion] + ); const { data: paths = [] } = useRequest2(() => getAppFolderPath(appId), { manual: false, @@ -104,7 +116,8 @@ const Header = ({ const onSwitchTmpVersion = useCallback( (data: SimpleAppSnapshotType, customTitle: string) => { - setAppForm(data.appForm); + const pastState = applyDiff(initialAppForm, data.diff); + setAppForm(pastState); // Remove multiple "copy-" const copyText = t('app:version_copy'); @@ -112,11 +125,11 @@ const Header = ({ const title = customTitle.replace(regex, `$1`); return saveSnapshot({ - appForm: data.appForm, + appForm: pastState, title }); }, - [saveSnapshot, setAppForm, t] + [initialAppForm, saveSnapshot, setAppForm, t] ); const onSwitchCloudVersion = useCallback( (appVersion: AppVersionSchemaType) => { @@ -143,7 +156,8 @@ const Header = ({ useDebounceEffect( () => { const savedSnapshot = past.find((snapshot) => snapshot.isSaved); - const val = compareSimpleAppSnapshot(savedSnapshot?.appForm, appForm); + const pastState = applyDiff(initialAppForm, savedSnapshot?.diff); + const val = compareSimpleAppSnapshot(pastState, appForm); setIsPublished(val); }, [past, allDatasets], diff --git a/projects/app/src/pages/app/detail/components/SimpleApp/useSnapshots.tsx b/projects/app/src/pages/app/detail/components/SimpleApp/useSnapshots.tsx index 6443d410c443..600cd013c415 100644 --- a/projects/app/src/pages/app/detail/components/SimpleApp/useSnapshots.tsx +++ b/projects/app/src/pages/app/detail/components/SimpleApp/useSnapshots.tsx @@ -3,11 +3,13 @@ import { SetStateAction, useEffect, useRef } from 'react'; import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; import { AppSimpleEditFormType } from '@fastgpt/global/core/app/type'; import { isEqual } from 'lodash'; +import { applyDiff, createDiff } from '@/web/core/app/diff'; export type SimpleAppSnapshotType = { - appForm: AppSimpleEditFormType; + diff?: Record; title: string; isSaved?: boolean; + state?: AppSimpleEditFormType; }; export type onSaveSnapshotFnType = (props: { appForm: AppSimpleEditFormType; @@ -66,14 +68,32 @@ export const useSimpleAppSnapshots = (appId: string) => { return false; } - const pastState = past[0]; + if (past.length === 0) { + setPast([ + { + title: title || formatTime2YMDHMS(new Date()), + isSaved, + state: appForm + } + ]); + return true; + } + + const initialState = past[past.length - 1].state; + if (!initialState) return false; + + if (past.length > 0) { + const pastState = applyDiff(initialState, past[0].diff); + + const isPastEqual = compareSimpleAppSnapshot(pastState, appForm); + if (isPastEqual) return false; + } - const isPastEqual = compareSimpleAppSnapshot(pastState?.appForm, appForm); - if (isPastEqual) return false; + const diff = createDiff(initialState, appForm); setPast((past) => [ { - appForm, + diff, title: title || formatTime2YMDHMS(new Date()), isSaved }, 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 2e28c6d7d2a6..c72888816ba6 100644 --- a/projects/app/src/pages/app/detail/components/Workflow/Header.tsx +++ b/projects/app/src/pages/app/detail/components/Workflow/Header.tsx @@ -13,7 +13,7 @@ import { useTranslation } from 'next-i18next'; import MyIcon from '@fastgpt/web/components/common/Icon'; import { useContextSelector } from 'use-context-selector'; -import { WorkflowContext } from '../WorkflowComponents/context'; +import { WorkflowContext, WorkflowStateType } from '../WorkflowComponents/context'; import { AppContext, TabEnum } from '../context'; import RouteTab from '../RouteTab'; import { useRouter } from 'next/router'; @@ -34,6 +34,7 @@ import { WorkflowInitContext } from '../WorkflowComponents/context/workflowInitContext'; import { WorkflowEventContext } from '../WorkflowComponents/context/workflowEventContext'; +import { applyDiff } from '@/web/core/app/diff'; const Header = () => { const { t } = useTranslation(); @@ -81,11 +82,14 @@ const Header = () => { [...future].reverse().find((snapshot) => snapshot.isSaved) || past.find((snapshot) => snapshot.isSaved); + const initialState = past[past.length - 1]?.state; + const savedSnapshotState = applyDiff(initialState, savedSnapshot?.diff); + const val = compareSnapshot( { - nodes: savedSnapshot?.nodes, - edges: savedSnapshot?.edges, - chatConfig: savedSnapshot?.chatConfig + nodes: savedSnapshotState?.nodes, + edges: savedSnapshotState?.edges, + chatConfig: savedSnapshotState?.chatConfig }, { nodes: nodes, diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx index 1c62953207e6..370e25f7fed8 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/Flow/components/ContextMenu.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'; import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils'; import { CommentNode } from '@fastgpt/global/core/workflow/template/system/comment'; import { useContextSelector } from 'use-context-selector'; -import { WorkflowContext } from '../../context'; import { useReactFlow } from 'reactflow'; import { WorkflowNodeEdgeContext } from '../../context/workflowInitContext'; import { WorkflowEventContext } from '../../context/workflowEventContext'; diff --git a/projects/app/src/pages/app/detail/components/WorkflowComponents/context/index.tsx b/projects/app/src/pages/app/detail/components/WorkflowComponents/context/index.tsx index 25ad6c2eab74..04c97049d7e5 100644 --- a/projects/app/src/pages/app/detail/components/WorkflowComponents/context/index.tsx +++ b/projects/app/src/pages/app/detail/components/WorkflowComponents/context/index.tsx @@ -2,6 +2,7 @@ import { postWorkflowDebug } from '@/web/core/workflow/api'; import { checkWorkflowNodeAndConnection, compareSnapshot, + simplifyNodes, storeEdgesRenderEdge, storeNode2FlowNode } from '@/web/core/workflow/utils'; @@ -41,6 +42,7 @@ import { cloneDeep } from 'lodash'; import { AppVersionSchemaType } from '@fastgpt/global/core/app/version'; import WorkflowInitContextProvider, { WorkflowNodeEdgeContext } from './workflowInitContext'; import WorkflowEventContextProvider from './workflowEventContext'; +import { applyDiff, createDiff } from '@/web/core/app/diff'; /* Context @@ -67,14 +69,22 @@ export const ReactFlowCustomProvider = ({ ); }; -type OnChange = (changes: ChangesType[]) => void; - export type WorkflowSnapshotsType = { + diff?: any; + title: string; + isSaved?: boolean; + state?: WorkflowStateType; + + // old format + nodes?: Node[]; + edges?: Edge[]; + chatConfig?: AppChatConfigType; +}; + +export type WorkflowStateType = { nodes: Node[]; edges: Edge[]; - title: string; chatConfig: AppChatConfigType; - isSaved?: boolean; }; type WorkflowContextType = { @@ -751,7 +761,7 @@ const WorkflowContextProvider = ({ defaultValue: [] }) as [WorkflowSnapshotsType[], (value: SetStateAction) => void]; - const resetSnapshot = useMemoizedFn((state: Omit) => { + const resetSnapshot = useMemoizedFn((state: WorkflowStateType) => { setNodes(state.nodes); setEdges(state.edges); setAppDetail((detail) => ({ @@ -759,20 +769,9 @@ const WorkflowContextProvider = ({ chatConfig: state.chatConfig })); }); + const pushPastSnapshot = useMemoizedFn( - ({ - pastNodes, - pastEdges, - customTitle, - chatConfig, - isSaved - }: { - pastNodes: Node[]; - pastEdges: Edge[]; - customTitle?: string; - chatConfig: AppChatConfigType; - isSaved?: boolean; - }) => { + ({ pastNodes, pastEdges, chatConfig, customTitle, isSaved }) => { if (!pastNodes || !pastEdges || !chatConfig) return false; if (forbiddenSaveSnapshot.current) { @@ -780,7 +779,13 @@ const WorkflowContextProvider = ({ return false; } - const pastState = past[0]; + // Get initial state + const initialState = past[past.length - 1]?.state; + if (!initialState) return false; + + // Apply latest diff to get past state + const pastState = applyDiff(initialState, past[0].diff); + const isPastEqual = compareSnapshot( { nodes: pastNodes, @@ -796,13 +801,21 @@ const WorkflowContextProvider = ({ if (isPastEqual) return false; + // Create current state object + const newState = { + nodes: simplifyNodes(pastNodes), + edges: pastEdges, + chatConfig + }; + + // Calculate diff from initial state + const diff = createDiff(initialState, newState); + setFuture([]); setPast((past) => [ { - nodes: pastNodes, - edges: pastEdges, + diff, title: customTitle || formatTime2YMDHMS(new Date()), - chatConfig, isSaved }, ...past.slice(0, 199) @@ -811,18 +824,20 @@ const WorkflowContextProvider = ({ return true; } ); + const onSwitchTmpVersion = useMemoizedFn((params: WorkflowSnapshotsType, customTitle: string) => { // Remove multiple "copy-" const copyText = t('app:version_copy'); const regex = new RegExp(`(${copyText}-)\\1+`, 'g'); const title = customTitle.replace(regex, `$1`); + const pastState = applyDiff(past[past.length - 1].state, params.diff); - resetSnapshot(params); + resetSnapshot(pastState); return pushPastSnapshot({ - pastNodes: params.nodes, - pastEdges: params.edges, - chatConfig: params.chatConfig, + pastNodes: pastState.nodes, + pastEdges: pastState.edges, + chatConfig: pastState.chatConfig, customTitle: title }); }); @@ -848,15 +863,19 @@ const WorkflowContextProvider = ({ if (past[1]) { setFuture((future) => [past[0], ...future]); setPast((past) => past.slice(1)); - resetSnapshot(past[1]); + const pastState = applyDiff(past[past.length - 1].state, past[1].diff); + resetSnapshot(pastState); } }); const redo = useMemoizedFn(() => { - const futureState = future[0]; + if (!future[0]) return; + + const futureState = applyDiff(past[past.length - 1].state, future[0].diff); if (futureState) { setPast((past) => [future[0], ...past]); setFuture((future) => future.slice(1)); + resetSnapshot(futureState); } }); @@ -873,45 +892,113 @@ const WorkflowContextProvider = ({ }); }, [appId]); + // 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 = createDiff(baseState, currentState); + + return { + title: item.title || formatTime2YMDHMS(new Date()), + isSaved: item.isSaved, + diff + }; + }); + }; + const initData = useCallback( - async (e: Parameters[0], isInit?: boolean) => { - // Refresh web page, load init + async ( + e: { + nodes: StoreNodeItemType[]; + edges: StoreEdgeItemType[]; + chatConfig?: AppChatConfigType; + }, + isInit?: boolean + ) => { + const nodes = e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || []; + const edges = e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []; + + const initialState = { + nodes: simplifyNodes(nodes), + edges, + chatConfig: e.chatConfig || appDetail.chatConfig + }; + if (isInit && past.length > 0) { - return resetSnapshot(past[0]); - } - // If it is the initial data, save the initial snapshot - if (isInit && past.length === 0) { - pushPastSnapshot({ - pastNodes: e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || [], - pastEdges: e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || [], - customTitle: t(`app:app.version_initial`), - chatConfig: appDetail.chatConfig, - isSaved: true - }); - forbiddenSaveSnapshot.current = true; + // new format + if (past[0].diff) { + const targetState = applyDiff( + past[past.length - 1].state, + past[0].diff + ) as WorkflowStateType; + + setNodes(targetState.nodes); + setEdges(targetState.edges); + setAppDetail((state) => ({ + ...state, + chatConfig: targetState.chatConfig + })); + return; + } + + // old format + if (past.some((item) => !item.state && (item.nodes || item.edges))) { + const newPast = convertOldFormatHistory(past); + + setPast(newPast); + + const latestState = applyDiff( + newPast[newPast.length - 1].state, + newPast[0].diff + ) as WorkflowStateType; + + setNodes(latestState.nodes); + setEdges(latestState.edges); + setAppDetail((state) => ({ + ...state, + chatConfig: latestState.chatConfig + })); + return; + } } - setNodes(e.nodes?.map((item) => storeNode2FlowNode({ item, t })) || []); - setEdges(e.edges?.map((item) => storeEdgesRenderEdge({ edge: item })) || []); + setNodes(nodes); + setEdges(edges); + if (e.chatConfig) { + setAppDetail((state) => ({ ...state, chatConfig: e.chatConfig as AppChatConfigType })); + } - const chatConfig = e.chatConfig; - if (chatConfig) { - setAppDetail((state) => ({ - ...state, - chatConfig - })); + if (isInit && past.length === 0) { + setPast([ + { + title: t(`app:app.version_initial`), + isSaved: true, + state: initialState + } + ]); + forbiddenSaveSnapshot.current = true; } }, - [ - appDetail.chatConfig, - past, - resetSnapshot, - pushPastSnapshot, - setAppDetail, - setEdges, - setNodes, - t - ] + [appDetail.chatConfig, past, setAppDetail, setEdges, setNodes, setPast, t] ); const value = useMemo( diff --git a/projects/app/src/web/core/app/diff.ts b/projects/app/src/web/core/app/diff.ts new file mode 100644 index 000000000000..6460b59e904f --- /dev/null +++ b/projects/app/src/web/core/app/diff.ts @@ -0,0 +1,20 @@ +import { create } from 'jsondiffpatch'; + +const createWorkflowDiffPatcher = () => + create({ + objectHash: (obj: any) => obj.id || obj.nodeId || obj._id, + propertyFilter: (name: string) => name !== 'selected' + }); + +const diffPatcher = createWorkflowDiffPatcher(); + +export const createDiff = >(initialState?: T, newState?: T) => { + return diffPatcher.diff(initialState, newState); +}; + +export const applyDiff = >( + initialState?: T, + diff?: ReturnType +) => { + return diffPatcher.patch(structuredClone(initialState), diff) as T; +}; diff --git a/projects/app/src/web/core/workflow/utils.ts b/projects/app/src/web/core/workflow/utils.ts index 5f2a21c94cdd..255c79cb0b6d 100644 --- a/projects/app/src/web/core/workflow/utils.ts +++ b/projects/app/src/web/core/workflow/utils.ts @@ -631,3 +631,14 @@ export const compareSnapshot = ( return isEqual(node1, node2); }; + +// remove node size +export const simplifyNodes = (nodes: Node[]) => { + return nodes.map((node) => ({ + id: node.id, + type: node.type, + position: node.position, + data: node.data, + zIndex: node.zIndex + })); +};