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)
This commit is contained in:
balibabu 2024-12-06 13:43:17 +08:00 committed by GitHub
parent c76e7b1e28
commit e64c7dfdf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 340 additions and 35 deletions

34
web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@ -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';

View File

@ -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',

View File

@ -1010,6 +1010,8 @@ export default {
testRun: '試運行',
template: '模板轉換',
templateDescription: '此元件用於排版各種元件的輸出。 ',
jsonUploadTypeErrorMessage: '請上傳json檔',
jsonUploadContentErrorMessage: 'json 檔案錯誤',
},
footer: {
profile: '“保留所有權利 @ react”',

View File

@ -1055,6 +1055,8 @@ export default {
ccEmailTip: 'cc_email: 抄送邮箱(可选)',
subjectTip: 'subject: 邮件主题(可选)',
contentTip: 'content: 邮件内容(可选)',
jsonUploadTypeErrorMessage: '请上传json文件',
jsonUploadContentErrorMessage: 'json 文件错误',
},
footer: {
profile: 'All rights reserved @ React',

View File

@ -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']}
>
<Background />
<Controls />
<Controls>
<ControlButton onClick={handleImportJson}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<FolderInput />
</TooltipTrigger>
<TooltipContent>Import</TooltipContent>
</Tooltip>
</TooltipProvider>
</ControlButton>
<ControlButton onClick={handleExportJson}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<FolderOutput />
</TooltipTrigger>
<TooltipContent>Export</TooltipContent>
</Tooltip>
</TooltipProvider>
</ControlButton>
</Controls>
</ReactFlow>
{formDrawerVisible && (
<FormDrawer
@ -214,6 +253,13 @@ function FlowCanvas({ drawerVisible, hideDrawer }: IProps) {
showModal={showChatModal}
></RunDrawer>
)}
{fileUploadVisible && (
<JsonUploadModal
onOk={onFileUploadOk}
visible={fileUploadVisible}
hideModal={hideFileUploadModal}
></JsonUploadModal>
)}
</div>
);
}

View File

@ -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,
};
};

View File

@ -0,0 +1,13 @@
.uploader {
:global {
.ant-upload-list {
max-height: 40vh;
overflow-y: auto;
}
}
}
.uploadLimit {
color: red;
font-size: 12px;
}

View File

@ -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<SetStateAction<UploadFile[]>>;
}) => {
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 (
<Dragger {...props} className={styles.uploader}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">{t('uploadTitle')}</p>
<p className="ant-upload-hint">{t('uploadDescription')}</p>
{false && <p className={styles.uploadLimit}>{t('uploadLimit')}</p>}
</Dragger>
);
};
const JsonUploadModal = ({
visible,
hideModal,
loading,
onOk: onFileUploadOk,
}: IModalProps<UploadFile[]>) => {
const { t } = useTranslate('fileManager');
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [directoryFileList, setDirectoryFileList] = useState<UploadFile[]>([]);
const clearFileList = () => {
setFileList([]);
setDirectoryFileList([]);
};
const onOk = async () => {
const ret = await onFileUploadOk?.([...fileList, ...directoryFileList]);
return ret;
};
const afterClose = () => {
clearFileList();
};
return (
<Modal
title={t('uploadFile')}
open={visible}
onOk={onOk}
onCancel={hideModal}
confirmLoading={loading}
afterClose={afterClose}
>
<FileUpload
directory={false}
fileList={fileList}
setFileList={setFileList}
></FileUpload>
</Modal>
);
};
export default JsonUploadModal;

View File

@ -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<string, any>,
fileName: string,
) => {
const blob = new Blob([JSON.stringify(data)], { type: FileMimeType.Json });
downloadFileFromBlob(blob, fileName);
};