From e64c7dfdf6f935971a825af8a96e932cc56ce9d8 Mon Sep 17 00:00:00 2001 From: balibabu Date: Fri, 6 Dec 2024 13:43:17 +0800 Subject: [PATCH] Feat: Import & export the agents. #3851 (#3894) ### What problem does this PR solve? Feat: Import & export the agents. #3851 ### Type of change - [x] New Feature (non-breaking change which adds functionality) --- web/package-lock.json | 34 +++++++ web/package.json | 1 + web/src/components/ui/tooltip.tsx | 30 ++++++ web/src/constants/common.ts | 43 ++++---- web/src/locales/en.ts | 2 + web/src/locales/zh-traditional.ts | 2 + web/src/locales/zh.ts | 2 + web/src/pages/flow/canvas/index.tsx | 48 ++++++++- web/src/pages/flow/hooks.tsx | 94 +++++++++++++++--- .../pages/flow/json-upload-modal/index.less | 13 +++ .../pages/flow/json-upload-modal/index.tsx | 97 +++++++++++++++++++ web/src/utils/file-util.ts | 9 ++ 12 files changed, 340 insertions(+), 35 deletions(-) create mode 100644 web/src/components/ui/tooltip.tsx create mode 100644 web/src/pages/flow/json-upload-modal/index.less create mode 100644 web/src/pages/flow/json-upload-modal/index.tsx diff --git a/web/package-lock.json b/web/package-lock.json index f8380b436..55f450db3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.4", "@tailwindcss/line-clamp": "^0.4.4", "@tanstack/react-query": "^5.40.0", "@tanstack/react-query-devtools": "^5.51.5", @@ -4891,6 +4892,39 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", + "integrity": "sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 63ecd6930..6bcea8f02 100644 --- a/web/package.json +++ b/web/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.4", "@tailwindcss/line-clamp": "^0.4.4", "@tanstack/react-query": "^5.40.0", "@tanstack/react-query-devtools": "^5.51.5", diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..25fe14c05 --- /dev/null +++ b/web/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/web/src/constants/common.ts b/web/src/constants/common.ts index 3f9440788..803632b74 100644 --- a/web/src/constants/common.ts +++ b/web/src/constants/common.ts @@ -66,27 +66,28 @@ export const LanguageTranslationMap = { Vietnamese: 'vi', }; -export const FileMimeTypeMap = { - bmp: 'image/bmp', - csv: 'text/csv', - odt: 'application/vnd.oasis.opendocument.text', - doc: 'application/msword', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - gif: 'image/gif', - htm: 'text/htm', - html: 'text/html', - jpg: 'image/jpg', - jpeg: 'image/jpeg', - pdf: 'application/pdf', - png: 'image/png', - ppt: 'application/vnd.ms-powerpoint', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - tiff: 'image/tiff', - txt: 'text/plain', - xls: 'application/vnd.ms-excel', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - mp4: 'video/mp4', -}; +export enum FileMimeType { + Bmp = 'image/bmp', + Csv = 'text/csv', + Odt = 'application/vnd.oasis.opendocument.text', + Doc = 'application/msword', + Docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + Gif = 'image/gif', + Htm = 'text/htm', + Html = 'text/html', + Jpg = 'image/jpg', + Jpeg = 'image/jpeg', + Pdf = 'application/pdf', + Png = 'image/png', + Ppt = 'application/vnd.ms-powerpoint', + Pptx = 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + Tiff = 'image/tiff', + Txt = 'text/plain', + Xls = 'application/vnd.ms-excel', + Xlsx = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + Mp4 = 'video/mp4', + Json = 'application/json', +} export const Domain = 'demo.ragflow.io'; diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 395e7d2d4..585a6f78e 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1077,6 +1077,8 @@ When you want to search the given knowledge base at first place, set a higher pa ccEmailTip: 'cc_email: CC email (Optional)', subjectTip: 'subject: Email subject (Optional)', contentTip: 'content: Email content (Optional)', + jsonUploadTypeErrorMessage: 'Please upload json file', + jsonUploadContentErrorMessage: 'json file error', }, footer: { profile: 'All rights reserved @ React', diff --git a/web/src/locales/zh-traditional.ts b/web/src/locales/zh-traditional.ts index 77f1d13ca..75e2f3722 100644 --- a/web/src/locales/zh-traditional.ts +++ b/web/src/locales/zh-traditional.ts @@ -1010,6 +1010,8 @@ export default { testRun: '試運行', template: '模板轉換', templateDescription: '此元件用於排版各種元件的輸出。 ', + jsonUploadTypeErrorMessage: '請上傳json檔', + jsonUploadContentErrorMessage: 'json 檔案錯誤', }, footer: { profile: '“保留所有權利 @ react”', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 2b8f8a0e4..e30912f61 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1055,6 +1055,8 @@ export default { ccEmailTip: 'cc_email: 抄送邮箱(可选)', subjectTip: 'subject: 邮件主题(可选)', contentTip: 'content: 邮件内容(可选)', + jsonUploadTypeErrorMessage: '请上传json文件', + jsonUploadContentErrorMessage: 'json 文件错误', }, footer: { profile: 'All rights reserved @ React', diff --git a/web/src/pages/flow/canvas/index.tsx b/web/src/pages/flow/canvas/index.tsx index d372f1198..4e42c0e80 100644 --- a/web/src/pages/flow/canvas/index.tsx +++ b/web/src/pages/flow/canvas/index.tsx @@ -1,8 +1,16 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { useSetModalState } from '@/hooks/common-hooks'; +import { FolderInput, FolderOutput } from 'lucide-react'; import { useCallback, useEffect } from 'react'; import ReactFlow, { Background, ConnectionMode, + ControlButton, Controls, NodeMouseHandler, } from 'reactflow'; @@ -13,12 +21,14 @@ import FormDrawer from '../flow-drawer'; import { useGetBeginNodeDataQuery, useHandleDrop, + useHandleExportOrImportJsonFile, useSelectCanvasData, useShowFormDrawer, useValidateConnection, useWatchNodeFormDataChange, } from '../hooks'; import { BeginQuery } from '../interface'; +import JsonUploadModal from '../json-upload-modal'; import RunDrawer from '../run-drawer'; import { ButtonEdge } from './edge'; import styles from './index.less'; @@ -115,6 +125,14 @@ function FlowCanvas({ drawerVisible, hideDrawer }: IProps) { const getBeginNodeDataQuery = useGetBeginNodeDataQuery(); + const { + handleExportJson, + handleImportJson, + fileUploadVisible, + onFileUploadOk, + hideFileUploadModal, + } = useHandleExportOrImportJsonFile(); + useEffect(() => { if (drawerVisible) { const query: BeginQuery[] = getBeginNodeDataQuery(); @@ -192,7 +210,28 @@ function FlowCanvas({ drawerVisible, hideDrawer }: IProps) { deleteKeyCode={['Delete', 'Backspace']} > - + + + + + + + + Import + + + + + + + + + + Export + + + + {formDrawerVisible && ( )} + {fileUploadVisible && ( + + )} ); } diff --git a/web/src/pages/flow/hooks.tsx b/web/src/pages/flow/hooks.tsx index 66e4efc94..a8f7df5c2 100644 --- a/web/src/pages/flow/hooks.tsx +++ b/web/src/pages/flow/hooks.tsx @@ -12,14 +12,16 @@ import React, { import { Connection, Edge, Node, Position, ReactFlowInstance } from 'reactflow'; // import { shallow } from 'zustand/shallow'; import { variableEnabledFieldMap } from '@/constants/chat'; +import { FileMimeType } from '@/constants/common'; import { ModelVariableType, settledModelVariableMap, } from '@/constants/knowledge'; import { useFetchModelId } from '@/hooks/logic-hooks'; import { Variable } from '@/interfaces/database/chat'; +import { downloadJsonFile } from '@/utils/file-util'; import { useDebounceEffect } from 'ahooks'; -import { FormInstance, message } from 'antd'; +import { FormInstance, UploadFile, message } from 'antd'; import { DefaultOptionType } from 'antd/es/select'; import dayjs from 'dayjs'; import { humanId } from 'human-id'; @@ -261,30 +263,45 @@ export const useShowFormDrawer = () => { }; }; -export const useSaveGraph = () => { +export const useBuildDslData = () => { const { data } = useFetchFlow(); - const { setFlow, loading } = useSetFlow(); - const { id } = useParams(); const { nodes, edges } = useGraphStore((state) => state); - useEffect(() => {}, [nodes]); - const saveGraph = useCallback( - async (currentNodes?: Node[]) => { + + const buildDslData = useCallback( + (currentNodes?: Node[]) => { const dslComponents = buildDslComponentsByGraph( currentNodes ?? nodes, edges, data.dsl.components, ); + + return { + ...data.dsl, + graph: { nodes: currentNodes ?? nodes, edges }, + components: dslComponents, + }; + }, + [data.dsl, edges, nodes], + ); + + return { buildDslData }; +}; + +export const useSaveGraph = () => { + const { data } = useFetchFlow(); + const { setFlow, loading } = useSetFlow(); + const { id } = useParams(); + const { buildDslData } = useBuildDslData(); + + const saveGraph = useCallback( + async (currentNodes?: Node[]) => { return setFlow({ id, title: data.title, - dsl: { - ...data.dsl, - graph: { nodes: currentNodes ?? nodes, edges }, - components: dslComponents, - }, + dsl: buildDslData(currentNodes), }); }, - [nodes, edges, setFlow, id, data], + [setFlow, id, data.title, buildDslData], ); return { saveGraph, loading }; @@ -774,3 +791,54 @@ export const useWatchAgentChange = (chatDrawerVisible: boolean) => { return time; }; + +export const useHandleExportOrImportJsonFile = () => { + const { buildDslData } = useBuildDslData(); + const { + visible: fileUploadVisible, + hideModal: hideFileUploadModal, + showModal: showFileUploadModal, + } = useSetModalState(); + const setGraphInfo = useSetGraphInfo(); + const { data } = useFetchFlow(); + const { t } = useTranslation(); + + const onFileUploadOk = useCallback( + async (fileList: UploadFile[]) => { + if (fileList.length > 0) { + const file: File = fileList[0] as unknown as File; + if (file.type !== FileMimeType.Json) { + message.error(t('flow.jsonUploadTypeErrorMessage')); + return; + } + + const graphStr = await file.text(); + const errorMessage = t('flow.jsonUploadContentErrorMessage'); + try { + const graph = JSON.parse(graphStr); + if (graphStr && !isEmpty(graph) && Array.isArray(graph?.nodes)) { + setGraphInfo(graph ?? ({} as IGraph)); + hideFileUploadModal(); + } else { + message.error(errorMessage); + } + } catch (error) { + message.error(errorMessage); + } + } + }, + [hideFileUploadModal, setGraphInfo, t], + ); + + const handleExportJson = useCallback(() => { + downloadJsonFile(buildDslData().graph, `${data.title}.json`); + }, [buildDslData, data.title]); + + return { + fileUploadVisible, + handleExportJson, + handleImportJson: showFileUploadModal, + hideFileUploadModal, + onFileUploadOk, + }; +}; diff --git a/web/src/pages/flow/json-upload-modal/index.less b/web/src/pages/flow/json-upload-modal/index.less new file mode 100644 index 000000000..8472339fe --- /dev/null +++ b/web/src/pages/flow/json-upload-modal/index.less @@ -0,0 +1,13 @@ +.uploader { + :global { + .ant-upload-list { + max-height: 40vh; + overflow-y: auto; + } + } +} + +.uploadLimit { + color: red; + font-size: 12px; +} diff --git a/web/src/pages/flow/json-upload-modal/index.tsx b/web/src/pages/flow/json-upload-modal/index.tsx new file mode 100644 index 000000000..085ecf349 --- /dev/null +++ b/web/src/pages/flow/json-upload-modal/index.tsx @@ -0,0 +1,97 @@ +import { useTranslate } from '@/hooks/common-hooks'; +import { IModalProps } from '@/interfaces/common'; +import { InboxOutlined } from '@ant-design/icons'; +import { Modal, Upload, UploadFile, UploadProps } from 'antd'; +import { Dispatch, SetStateAction, useState } from 'react'; + +import { FileMimeType } from '@/constants/common'; + +import styles from './index.less'; + +const { Dragger } = Upload; + +const FileUpload = ({ + directory, + fileList, + setFileList, +}: { + directory: boolean; + fileList: UploadFile[]; + setFileList: Dispatch>; +}) => { + const { t } = useTranslate('fileManager'); + const props: UploadProps = { + multiple: false, + accept: FileMimeType.Json, + onRemove: (file) => { + const index = fileList.indexOf(file); + const newFileList = fileList.slice(); + newFileList.splice(index, 1); + setFileList(newFileList); + }, + beforeUpload: (file) => { + setFileList(() => { + return [file]; + }); + + return false; + }, + directory, + fileList, + }; + + return ( + +

+ +

+

{t('uploadTitle')}

+

{t('uploadDescription')}

+ {false &&

{t('uploadLimit')}

} +
+ ); +}; + +const JsonUploadModal = ({ + visible, + hideModal, + loading, + onOk: onFileUploadOk, +}: IModalProps) => { + const { t } = useTranslate('fileManager'); + const [fileList, setFileList] = useState([]); + const [directoryFileList, setDirectoryFileList] = useState([]); + + const clearFileList = () => { + setFileList([]); + setDirectoryFileList([]); + }; + + const onOk = async () => { + const ret = await onFileUploadOk?.([...fileList, ...directoryFileList]); + return ret; + }; + + const afterClose = () => { + clearFileList(); + }; + + return ( + + + + ); +}; + +export default JsonUploadModal; diff --git a/web/src/utils/file-util.ts b/web/src/utils/file-util.ts index 9645b560a..cde7f0e6b 100644 --- a/web/src/utils/file-util.ts +++ b/web/src/utils/file-util.ts @@ -1,3 +1,4 @@ +import { FileMimeType } from '@/constants/common'; import fileManagerService from '@/services/file-manager-service'; import { UploadFile } from 'antd'; @@ -137,3 +138,11 @@ export const formatBytes = (x: string | number) => { return n.toFixed(n < 10 && l > 0 ? 1 : 0) + ' ' + Units[l]; }; + +export const downloadJsonFile = async ( + data: Record, + fileName: string, +) => { + const blob = new Blob([JSON.stringify(data)], { type: FileMimeType.Json }); + downloadFileFromBlob(blob, fileName); +};