perf: http body;perf: create by json;perf: create by curl (#3570)

* perf: http body

* feat: create app by json (#3557)

* feat: create app by json

* fix build

* perf: create by json;perf: create by curl

* fix: ts

---------

Co-authored-by: heheer <heheer@sealos.io>
This commit is contained in:
Archer
2025-01-12 22:49:03 +08:00
committed by GitHub
parent f1f0ae2839
commit d0d1a2cae8
34 changed files with 1280 additions and 520 deletions

View File

@@ -10,7 +10,6 @@
"test": "jest"
},
"dependencies": {
"@bany/curl-to-json": "^1.2.8",
"@chakra-ui/anatomy": "2.2.1",
"@chakra-ui/icons": "2.1.1",
"@chakra-ui/next-js": "2.1.5",

View File

@@ -0,0 +1,136 @@
import React, { DragEvent, useCallback, useState } from 'react';
import { Box, Button, Flex, Textarea } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { useToast } from '@fastgpt/web/hooks/useToast';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
type Props = {
value: string;
onChange: (value: string) => void;
rows?: number;
};
const ImportAppConfigEditor = ({ value, onChange, rows = 16 }: Props) => {
const { t } = useTranslation();
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const { File, onOpen } = useSelectFile({
fileType: 'json',
multiple: false
});
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
}, []);
const readJSONFile = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
if (!file.name.endsWith('.json')) {
toast({
title: t('app:not_json_file'),
status: 'error'
});
return;
}
if (e.target) {
const res = JSON.parse(e.target.result as string);
onChange(JSON.stringify(res, null, 2));
}
};
reader.readAsText(file);
},
[onChange, t, toast]
);
const onSelectFile = useCallback(
async (e: File[]) => {
const file = e[0];
readJSONFile(file);
},
[readJSONFile]
);
const handleDrop = useCallback(
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
console.log(file);
readJSONFile(file);
setIsDragging(false);
},
[readJSONFile]
);
return (
<>
<Box w={['100%', '31rem']}>
{isDragging ? (
<Flex
align={'center'}
justify={'center'}
w={'full'}
h={'17.5rem'}
borderRadius={'md'}
border={'1px dashed'}
borderColor={'myGray.400'}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onDragLeave={handleDragLeave}
>
<Flex align={'center'} justify={'center'} flexDir={'column'} gap={'0.62rem'}>
<MyIcon name={'configmap'} w={'2rem'} color={'primary.500'} />
<Box color={'primary.600'} fontSize={'sm'}>
{t('app:file_recover')}
</Box>
</Flex>
</Flex>
) : (
<Box>
<Flex justify={'space-between'} align={'center'} pb={3}>
<Box fontSize={'sm'} color={'myGray.900'} fontWeight={'500'}>
{t('common:common.json_config')}
</Box>
<Button onClick={onOpen} variant={'whiteBase'} p={0}>
<Flex px={'0.88rem'} py={'0.44rem'} color={'myGray.600'} fontSize={'mini'}>
<MyIcon name={'file/uploadFile'} w={'1rem'} mr={'0.38rem'} />
{t('common:common.upload_file')}
</Flex>
</Button>
</Flex>
<Box
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onDragLeave={handleDragLeave}
>
<Textarea
bg={'myGray.50'}
border={'1px solid'}
borderRadius={'md'}
borderColor={'myGray.200'}
value={value}
placeholder={t('app:or_drag_JSON')}
rows={rows}
onChange={(e) => onChange(e.target.value)}
/>
</Box>
</Box>
)}
</Box>
{File && <File onSelect={onSelectFile} />}
</>
);
};
export default React.memo(ImportAppConfigEditor);

View File

@@ -0,0 +1,23 @@
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { i18nT } from '@fastgpt/web/i18n/utils';
export const appTypeMap = {
[AppTypeEnum.simple]: {
icon: 'core/app/simpleBot',
title: i18nT('app:type.Create simple bot'),
avatar: 'core/app/type/simpleFill',
emptyCreateText: i18nT('app:create_empty_app')
},
[AppTypeEnum.workflow]: {
icon: 'core/app/type/workflowFill',
avatar: 'core/app/type/workflowFill',
title: i18nT('app:type.Create workflow bot'),
emptyCreateText: i18nT('app:create_empty_workflow')
},
[AppTypeEnum.plugin]: {
icon: 'core/app/type/pluginFill',
avatar: 'core/app/type/pluginFill',
title: i18nT('app:type.Create plugin bot'),
emptyCreateText: i18nT('app:create_empty_plugin')
}
};

View File

@@ -0,0 +1,126 @@
import React, { useCallback } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { filterSensitiveNodesData } from '@/web/core/workflow/utils';
import { useCopyData } from '@/web/common/hooks/useCopyData';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import { fileDownload } from '@/web/common/file/utils';
import { AppChatConfigType, AppSimpleEditFormType } from '@fastgpt/global/core/app/type';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { filterSensitiveFormData } from '@/web/core/app/utils';
import { RequireOnlyOne } from '@fastgpt/global/common/type/utils';
import { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { StoreEdgeItemType } from '@fastgpt/global/core/workflow/type/edge';
const ExportConfigPopover = ({
appForm,
getWorkflowData,
chatConfig,
appName
}: {
appName: string;
chatConfig?: AppChatConfigType;
} & RequireOnlyOne<{
getWorkflowData: () =>
| {
nodes: StoreNodeItemType[];
edges: StoreEdgeItemType[];
}
| undefined;
appForm: AppSimpleEditFormType;
}>) => {
const { t } = useTranslation();
const { copyData } = useCopyData();
const onExportWorkflow = useCallback(
async (mode: 'copy' | 'json') => {
let config = '';
if (appForm) {
config = JSON.stringify(filterSensitiveFormData(appForm), null, 2);
} else if (getWorkflowData) {
const workflowData = getWorkflowData();
if (!workflowData) return;
config = JSON.stringify(
{
nodes: filterSensitiveNodesData(workflowData.nodes),
edges: workflowData.edges,
chatConfig
},
null,
2
);
}
if (!config) {
return;
}
if (mode === 'copy') {
copyData(config, t('app:export_config_successful'));
} else if (mode === 'json') {
fileDownload({
text: config,
type: 'application/json;charset=utf-8',
filename: `${appName}.json`
});
}
},
[appForm, appName, chatConfig, copyData, getWorkflowData, t]
);
return (
<MyPopover
placement={'right-start'}
offset={[0, 20]}
hasArrow
trigger={'hover'}
w={'8.6rem'}
Trigger={
<MyBox display={'flex'} cursor={'pointer'}>
<MyIcon name={'export'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('app:export_configs')}</Box>
</MyBox>
}
>
{({ onClose }) => (
<Box p={1}>
<Flex
py={'0.38rem'}
px={1}
color={'myGray.600'}
_hover={{
bg: 'myGray.05',
color: 'primary.600',
cursor: 'pointer'
}}
borderRadius={'xs'}
onClick={() => onExportWorkflow('copy')}
>
<MyIcon name={'copy'} w={'1rem'} mr={2} />
<Box fontSize={'mini'}>{t('common:common.copy_to_clipboard')}</Box>
</Flex>
<Flex
py={'0.38rem'}
px={1}
color={'myGray.600'}
_hover={{
bg: 'myGray.05',
color: 'primary.600',
cursor: 'pointer'
}}
borderRadius={'xs'}
onClick={() => onExportWorkflow('json')}
>
<MyIcon name={'configmap'} w={'1rem'} mr={2} />
<Box fontSize={'mini'}>{t('common:common.export_to_json')}</Box>
</Flex>
</Box>
)}
</MyPopover>
);
};
export default React.memo(ExportConfigPopover);

View File

@@ -24,6 +24,7 @@ import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { postTransition2Workflow } from '@/web/core/app/api/app';
import { form2AppWorkflow } from '@/web/core/app/utils';
import { SimpleAppSnapshotType } from './useSnapshots';
import ExportConfigPopover from '@/pageComponents/app/detail/ExportConfigPopover';
const AppCard = ({
appForm,
@@ -118,6 +119,7 @@ const AppCard = ({
)}
{appDetail.permission.isOwner && (
<MyMenu
size={'xs'}
Button={
<IconButton
variant={'whitePrimary'}
@@ -129,6 +131,17 @@ const AppCard = ({
menuList={[
{
children: [
{
label: (
<Flex>
<ExportConfigPopover
appName={appDetail.name}
appForm={appForm}
chatConfig={appDetail.chatConfig}
/>
</Flex>
)
},
{
icon: 'core/app/type/workflow',
label: t('app:transition_to_workflow'),

View File

@@ -6,27 +6,27 @@ import { useTranslation } from 'next-i18next';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { WorkflowContext } from './context';
import { filterSensitiveNodesData } from '@/web/core/workflow/utils';
import dynamic from 'next/dynamic';
import { useCopyData } from '@/web/common/hooks/useCopyData';
import MyTag from '@fastgpt/web/components/common/Tag/index';
import { publishStatusStyle } from '../constants';
import MyPopover from '@fastgpt/web/components/common/MyPopover';
import { fileDownload } from '@/web/common/file/utils';
import { AppChatConfigType } from '@fastgpt/global/core/app/type';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useSystemStore } from '@/web/common/system/useSystemStore';
const ImportSettings = dynamic(() => import('./Flow/ImportSettings'));
const ExportConfigPopover = dynamic(
() => import('@/pageComponents/app/detail/ExportConfigPopover')
);
const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved: boolean }) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { appDetail, onOpenInfoEdit, onOpenTeamTagModal, onDelApp } = useContextSelector(
AppContext,
(v) => v
);
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const onOpenInfoEdit = useContextSelector(AppContext, (v) => v.onOpenInfoEdit);
const onOpenTeamTagModal = useContextSelector(AppContext, (v) => v.onOpenTeamTagModal);
const onDelApp = useContextSelector(AppContext, (v) => v.onDelApp);
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
const { isOpen: isOpenImport, onOpen: onOpenImport, onClose: onCloseImport } = useDisclosure();
@@ -92,10 +92,11 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
_hover={{ color: 'primary.600', bg: 'rgba(17, 24, 36, 0.05)' }}
cursor={'pointer'}
>
{ExportPopover({
chatConfig: appDetail.chatConfig,
appName: appDetail.name
})}
<ExportConfigPopover
chatConfig={appDetail.chatConfig}
appName={appDetail.name}
getWorkflowData={flowData2StoreData}
/>
</MyBox>
{appDetail.permission.hasWritePer && feConfigs?.show_team_chat && (
<>
@@ -148,6 +149,7 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
appDetail.permission.hasWritePer,
appDetail.permission.isOwner,
feConfigs?.show_team_chat,
flowData2StoreData,
onDelApp,
onOpenImport,
onOpenInfoEdit,
@@ -210,104 +212,4 @@ const AppCard = ({ showSaveStatus, isSaved }: { showSaveStatus: boolean; isSaved
return Render;
};
function ExportPopover({
chatConfig,
appName
}: {
chatConfig: AppChatConfigType;
appName: string;
}) {
const { t } = useTranslation();
const { copyData } = useCopyData();
const flowData2StoreData = useContextSelector(WorkflowContext, (v) => v.flowData2StoreData);
const onExportWorkflow = useCallback(
async (mode: 'copy' | 'json') => {
const data = flowData2StoreData();
if (data) {
if (mode === 'copy') {
copyData(
JSON.stringify(
{
nodes: filterSensitiveNodesData(data.nodes),
edges: data.edges,
chatConfig
},
null,
2
),
t('app:export_config_successful')
);
} else if (mode === 'json') {
fileDownload({
text: JSON.stringify(
{
nodes: filterSensitiveNodesData(data.nodes),
edges: data.edges,
chatConfig
},
null,
2
),
type: 'application/json;charset=utf-8',
filename: `${appName}.json`
});
}
}
},
[appName, chatConfig, copyData, flowData2StoreData, t]
);
return (
<MyPopover
placement={'right-start'}
offset={[0, 20]}
hasArrow
trigger={'hover'}
w={'8.6rem'}
Trigger={
<MyBox display={'flex'} size={'md'} rounded={'4px'} cursor={'pointer'}>
<MyIcon name={'export'} w={'16px'} mr={2} />
<Box fontSize={'sm'}>{t('app:export_configs')}</Box>
</MyBox>
}
>
{({ onClose }) => (
<Box p={1}>
<Flex
py={'0.38rem'}
px={1}
color={'myGray.600'}
_hover={{
bg: 'myGray.05',
color: 'primary.600',
cursor: 'pointer'
}}
borderRadius={'xs'}
onClick={() => onExportWorkflow('copy')}
>
<MyIcon name={'copy'} w={'1rem'} mr={2} />
<Box fontSize={'mini'}>{t('common:common.copy_to_clipboard')}</Box>
</Flex>
<Flex
py={'0.38rem'}
px={1}
color={'myGray.600'}
_hover={{
bg: 'myGray.05',
color: 'primary.600',
cursor: 'pointer'
}}
borderRadius={'xs'}
onClick={() => onExportWorkflow('json')}
>
<MyIcon name={'configmap'} w={'1rem'} mr={2} />
<Box fontSize={'mini'}>{t('common:common.export_to_json')}</Box>
</Flex>
</Box>
)}
</MyPopover>
);
}
export default AppCard;

View File

@@ -1,13 +1,15 @@
import React, { DragEvent, useCallback, useMemo, useState } from 'react';
import { Textarea, Button, ModalBody, ModalFooter, Flex, Box } from '@chakra-ui/react';
import React, { useState } from 'react';
import { Button, ModalBody, ModalFooter } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../context';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import dynamic from 'next/dynamic';
const ImportAppConfigEditor = dynamic(() => import('@/pageComponents/app/ImportAppConfigEditor'), {
ssr: false
});
type Props = {
onClose: () => void;
@@ -15,63 +17,10 @@ type Props = {
const ImportSettings = ({ onClose }: Props) => {
const { toast } = useToast();
const { File, onOpen } = useSelectFile({
fileType: 'json',
multiple: false
});
const { isPc } = useSystem();
const initData = useContextSelector(WorkflowContext, (v) => v.initData);
const [isDragging, setIsDragging] = useState(false);
const [value, setValue] = useState('');
const { t } = useTranslation();
const readJSONFile = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
if (!file.name.endsWith('.json')) {
toast({
title: t('app:not_json_file'),
status: 'error'
});
return;
}
if (e.target) {
const res = JSON.parse(e.target.result as string);
setValue(JSON.stringify(res, null, 2));
}
};
reader.readAsText(file);
},
[t, toast]
);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
readJSONFile(file);
setIsDragging(false);
},
[readJSONFile]
);
const onSelectFile = useCallback(
async (e: File[]) => {
const file = e[0];
readJSONFile(file);
},
[readJSONFile]
);
const [value, setValue] = useState('');
return (
<MyModal
@@ -80,67 +29,10 @@ const ImportSettings = ({ onClose }: Props) => {
iconSrc="common/importLight"
iconColor="primary.600"
title={t('app:import_configs')}
size={isPc ? 'lg' : 'md'}
size={'md'}
>
<ModalBody>
<File onSelect={onSelectFile} />
{isDragging ? (
<Flex
align={'center'}
justify={'center'}
w={'31rem'}
h={'17.5rem'}
borderRadius={'md'}
border={'1px dashed'}
borderColor={'myGray.400'}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onDragLeave={handleDragLeave}
>
<Flex align={'center'} justify={'center'} flexDir={'column'} gap={'0.62rem'}>
<MyIcon name={'configmap'} w={'1.5rem'} color={'myGray.500'} />
<Box color={'myGray.600'} fontSize={'mini'}>
{t('app:file_recover')}
</Box>
</Flex>
</Flex>
) : (
<Box w={['100%', '31rem']}>
<Flex justify={'space-between'} align={'center'} pb={2}>
<Box fontSize={'sm'} color={'myGray.900'} fontWeight={'500'}>
{t('common:common.json_config')}
</Box>
<Button onClick={onOpen} variant={'whiteBase'} p={0}>
<Flex px={'0.88rem'} py={'0.44rem'} color={'myGray.600'} fontSize={'mini'}>
<MyIcon name={'file/uploadFile'} w={'1rem'} mr={'0.38rem'} />
{t('common:common.upload_file')}
</Flex>
</Button>
</Flex>
<Box
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onDragLeave={handleDragLeave}
>
<Textarea
bg={'myGray.50'}
border={'1px solid'}
borderRadius={'md'}
borderColor={'myGray.200'}
value={value}
placeholder={
isPc
? t('app:paste_config') + '\n' + t('app:or_drag_JSON')
: t('app:paste_config')
}
rows={16}
onChange={(e) => setValue(e.target.value)}
/>
</Box>
</Box>
)}
<ImportAppConfigEditor value={value} onChange={setValue} rows={16} />
</ModalBody>
<ModalFooter justifyItems={'flex-end'}>
<Button

View File

@@ -1,23 +1,14 @@
import React from 'react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { ModalBody, Button, ModalFooter, useDisclosure, Textarea, Box } from '@chakra-ui/react';
import { ModalBody, Button, ModalFooter, Textarea } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io.d';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useForm } from 'react-hook-form';
import parse from '@bany/curl-to-json';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../../context';
type RequestMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
const methodMap: { [K in RequestMethod]: string } = {
get: 'GET',
post: 'POST',
put: 'PUT',
delete: 'DELETE',
patch: 'PATCH'
};
import { parseCurl } from '@fastgpt/global/common/string/http';
const CurlImportModal = ({
nodeId,
@@ -49,22 +40,7 @@ const CurlImportModal = ({
if (!requestUrl || !requestMethod || !params || !headers || !jsonBody) return;
const parsed = parse(content);
if (!parsed.url) {
throw new Error('url not found');
}
const newParams = Object.keys(parsed.params || {}).map((key) => ({
key,
value: parsed.params?.[key],
type: 'string'
}));
const newHeaders = Object.keys(parsed.header || {}).map((key) => ({
key,
value: parsed.header?.[key],
type: 'string'
}));
const newBody = JSON.stringify(parsed.data, null, 2);
const parsed = parseCurl(content);
onChangeNode({
nodeId,
@@ -82,7 +58,7 @@ const CurlImportModal = ({
key: NodeInputKeyEnum.httpMethod,
value: {
...requestMethod,
value: methodMap[parsed.method?.toLowerCase() as RequestMethod] || 'GET'
value: parsed.method
}
});
@@ -92,7 +68,7 @@ const CurlImportModal = ({
key: NodeInputKeyEnum.httpParams,
value: {
...params,
value: newParams
value: parsed.params
}
});
@@ -102,7 +78,7 @@ const CurlImportModal = ({
key: NodeInputKeyEnum.httpHeaders,
value: {
...headers,
value: newHeaders
value: parsed.headers
}
});
@@ -112,7 +88,7 @@ const CurlImportModal = ({
key: NodeInputKeyEnum.httpJsonBody,
value: {
...jsonBody,
value: newBody
value: parsed.body
}
});

View File

@@ -1,10 +1,20 @@
import React, { useRef } from 'react';
import { Box, Flex, Button, ModalBody, Input, Grid, Card } from '@chakra-ui/react';
import React, { useState } from 'react';
import {
Box,
Flex,
Button,
ModalBody,
Input,
Grid,
Card,
Textarea,
ModalFooter
} from '@chakra-ui/react';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useForm } from 'react-hook-form';
import { postCreateApp } from '@/web/core/app/api';
import { useRouter } from 'next/router';
import { emptyTemplates } from '@/web/core/app/templates';
import { emptyTemplates, parsePluginFromCurlString } from '@/web/core/app/templates';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
@@ -21,10 +31,13 @@ import {
getTemplateMarketItemList
} from '@/web/core/app/api/template';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs';
import { appTypeMap } from '@/pageComponents/app/constants';
type FormType = {
avatar: string;
name: string;
curlContent: string;
};
export type CreateAppType = AppTypeEnum.simple | AppTypeEnum.workflow | AppTypeEnum.plugin;
@@ -44,27 +57,10 @@ const CreateModal = ({
const { isPc } = useSystem();
const { feConfigs } = useSystemStore();
const typeMap = useRef({
[AppTypeEnum.simple]: {
icon: 'core/app/simpleBot',
title: t('app:type.Create simple bot'),
avatar: 'core/app/type/simpleFill',
emptyCreateText: t('app:create_empty_app')
},
[AppTypeEnum.workflow]: {
icon: 'core/app/type/workflowFill',
avatar: 'core/app/type/workflowFill',
title: t('app:type.Create workflow bot'),
emptyCreateText: t('app:create_empty_workflow')
},
[AppTypeEnum.plugin]: {
icon: 'core/app/type/pluginFill',
avatar: 'core/app/type/pluginFill',
title: t('app:type.Create plugin bot'),
emptyCreateText: t('app:create_empty_plugin')
}
});
const typeData = typeMap.current[type];
const [currentCreateType, setCurrentCreateType] = useState<'template' | 'curl'>('template');
const isTemplateMode = currentCreateType === 'template';
const typeData = appTypeMap[type];
const { data: templateList = [], loading: isRequestTemplates } = useRequest2(
() => getTemplateMarketItemList({ isQuickTemplate: true, type }),
{
@@ -75,11 +71,12 @@ const CreateModal = ({
const { register, setValue, watch, handleSubmit } = useForm<FormType>({
defaultValues: {
avatar: typeData.avatar,
name: ''
name: '',
curlContent: ''
}
});
const avatar = watch('avatar');
const avatar = watch('avatar');
const {
File,
onOpen: onOpenSelectFile,
@@ -90,12 +87,13 @@ const CreateModal = ({
});
const { runAsync: onclickCreate, loading: isCreating } = useRequest2(
async (data: FormType, templateId?: string) => {
if (!templateId) {
async ({ avatar, name, curlContent }: FormType, templateId?: string) => {
// From empty template
if (!templateId && currentCreateType !== 'curl') {
return postCreateApp({
parentId,
avatar: data.avatar,
name: data.name,
avatar: avatar,
name: name,
type,
modules: emptyTemplates[type].nodes,
edges: emptyTemplates[type].edges,
@@ -103,15 +101,31 @@ const CreateModal = ({
});
}
const templateDetail = await getTemplateMarketItemDetail(templateId);
const { workflow, appAvatar } = await (async () => {
if (templateId) {
const templateDetail = await getTemplateMarketItemDetail(templateId);
return {
appAvatar: templateDetail.avatar,
workflow: templateDetail.workflow
};
}
if (curlContent) {
return {
appAvatar: avatar,
workflow: parsePluginFromCurlString(curlContent)
};
}
return Promise.reject('No template or curl content');
})();
return postCreateApp({
parentId,
avatar: data.avatar || templateDetail.avatar,
name: data.name,
type: templateDetail.type as AppTypeEnum,
modules: templateDetail.workflow.nodes || [],
edges: templateDetail.workflow.edges || [],
chatConfig: templateDetail.workflow.chatConfig
avatar: appAvatar,
name: name,
type,
modules: workflow.nodes || [],
edges: workflow.edges || [],
chatConfig: workflow.chatConfig || {}
});
},
{
@@ -128,14 +142,13 @@ const CreateModal = ({
return (
<MyModal
iconSrc={typeData.icon}
title={typeData.title}
title={t(typeData.title)}
isOpen
onClose={onClose}
isCentered={!isPc}
maxW={['90vw', '40rem']}
isLoading={isCreating || isRequestTemplates}
>
<ModalBody px={9} pb={8}>
<ModalBody>
<Box color={'myGray.800'} fontWeight={'bold'}>
{t('common:common.Set Name')}
</Box>
@@ -161,111 +174,151 @@ const CreateModal = ({
})}
/>
</Flex>
<Flex mt={[4, 7]} mb={[0, 3]}>
<Box color={'myGray.900'} fontWeight={'bold'} fontSize={'sm'}>
{t('common:core.app.Select app from template')}
</Box>
<Box flex={1} />
<Flex
onClick={() => onOpenTemplateModal(type)}
alignItems={'center'}
cursor={'pointer'}
color={'myGray.600'}
fontSize={'xs'}
_hover={{ color: 'blue.700' }}
>
{t('common:core.app.more')}
<ChevronRightIcon w={4} h={4} />
</Flex>
</Flex>
<Grid
userSelect={'none'}
gridTemplateColumns={templateList.length > 0 ? ['repeat(1,1fr)', 'repeat(2,1fr)'] : '1fr'}
gridGap={[2, 4]}
>
<Card
borderWidth={'1px'}
borderRadius={'md'}
cursor={'pointer'}
boxShadow={'3'}
display={'flex'}
flexDirection={'column'}
alignItems={'center'}
justifyContent={'center'}
color={'myGray.500'}
borderColor={'myGray.200'}
h={'8.25rem'}
_hover={{
color: 'primary.700',
borderColor: 'primary.300'
}}
onClick={handleSubmit((data) => onclickCreate(data))}
>
<MyIcon name={'common/addLight'} w={'1.5rem'} />
<Box fontSize={'sm'} mt={2}>
{typeData.emptyCreateText}
<Flex mt={[4, 7]} mb={3}>
{type === AppTypeEnum.plugin ? (
<FillRowTabs
list={[
{ label: t('app:create_by_template'), value: 'template' },
{ label: t('app:create_by_curl'), value: 'curl' }
]}
value={currentCreateType}
fontSize={'xs'}
onChange={(e) => setCurrentCreateType(e as 'template' | 'curl')}
/>
) : (
<Box color={'myGray.900'} fontWeight={'bold'} fontSize={'sm'}>
{t('app:create_by_template')}
</Box>
</Card>
{templateList.map((item) => (
)}
<Box flex={1} />
{isTemplateMode && (
<Flex
onClick={() => onOpenTemplateModal(type)}
alignItems={'center'}
cursor={'pointer'}
color={'myGray.600'}
fontSize={'xs'}
_hover={{ color: 'blue.700' }}
>
{t('common:core.app.more')}
<ChevronRightIcon w={4} h={4} />
</Flex>
)}
</Flex>
{isTemplateMode ? (
<Grid
userSelect={'none'}
gridTemplateColumns={
templateList.length > 0 ? ['repeat(1,1fr)', 'repeat(2,1fr)'] : '1fr'
}
gridGap={[2, 4]}
>
<Card
key={item.templateId}
p={4}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
borderRadius={'md'}
cursor={'pointer'}
boxShadow={'3'}
h={'8.25rem'}
_hover={{
borderColor: 'primary.300',
'& .buttons': {
display: 'flex'
}
}}
display={'flex'}
flexDirection={'column'}
alignItems={'center'}
justifyContent={'center'}
color={'myGray.500'}
borderColor={'myGray.200'}
h={'8.25rem'}
_hover={{
color: 'primary.700',
borderColor: 'primary.300'
}}
onClick={handleSubmit((data) => onclickCreate(data))}
>
<Flex alignItems={'center'}>
<Avatar src={item.avatar} borderRadius={'sm'} w={'1.5rem'} />
<Box ml={3} color={'myGray.900'} fontWeight={500}>
{t(item.name as any)}
</Box>
</Flex>
<Box fontSize={'xs'} mt={2} color={'myGray.600'} flex={1}>
{t(item.intro as any)}
</Box>
<Box w={'full'} fontSize={'mini'}>
<Box color={'myGray.500'}>{`By ${item.author || feConfigs.systemTitle}`}</Box>
<Box
className="buttons"
display={'none'}
justifyContent={'center'}
alignItems={'center'}
position={'absolute'}
borderRadius={'lg'}
w={'full'}
h={'full'}
left={0}
right={0}
bottom={1}
height={'40px'}
bg={'white'}
zIndex={1}
>
<Button
variant={'whiteBase'}
h={6}
borderRadius={'sm'}
w={'40%'}
onClick={handleSubmit((data) => onclickCreate(data, item.templateId))}
>
{t('app:templateMarket.Use')}
</Button>
</Box>
<MyIcon name={'common/addLight'} w={'1.5rem'} />
<Box fontSize={'sm'} mt={2}>
{t(typeData.emptyCreateText)}
</Box>
</Card>
))}
</Grid>
{templateList.map((item) => (
<Card
key={item.templateId}
p={4}
borderRadius={'md'}
borderWidth={'1px'}
borderColor={'myGray.200'}
boxShadow={'3'}
h={'8.25rem'}
_hover={{
borderColor: 'primary.300',
'& .buttons': {
display: 'flex'
}
}}
display={'flex'}
flexDirection={'column'}
>
<Flex alignItems={'center'}>
<Avatar src={item.avatar} borderRadius={'sm'} w={'1.5rem'} />
<Box ml={3} color={'myGray.900'} fontWeight={500}>
{t(item.name as any)}
</Box>
</Flex>
<Box fontSize={'xs'} mt={2} color={'myGray.600'} flex={1}>
{t(item.intro as any)}
</Box>
<Box w={'full'} fontSize={'mini'}>
<Box color={'myGray.500'}>{`By ${item.author || feConfigs.systemTitle}`}</Box>
<Box
className="buttons"
display={'none'}
justifyContent={'center'}
alignItems={'center'}
position={'absolute'}
borderRadius={'lg'}
w={'full'}
h={'full'}
left={0}
right={0}
bottom={1}
height={'40px'}
bg={'white'}
zIndex={1}
>
<Button
variant={'whiteBase'}
h={6}
borderRadius={'sm'}
w={'40%'}
onClick={handleSubmit((data) => onclickCreate(data, item.templateId))}
>
{t('app:templateMarket.Use')}
</Button>
</Box>
</Box>
</Card>
))}
</Grid>
) : (
<Box>
<Textarea
placeholder={t('app:oaste_curl_string')}
w={'560px'}
h={'260px'}
bg={'myGray.50'}
{...register('curlContent')}
/>
</Box>
)}
</ModalBody>
<ModalFooter gap={4}>
<Button variant={'whiteBase'} onClick={onClose}>
{t('common:common.Cancel')}
</Button>
<Button variant={'primary'} onClick={handleSubmit((data) => onclickCreate(data))}>
{t('common:common.Confirm')}
</Button>
</ModalFooter>
<File
onSelect={(e) =>
onSelectImage(e, {

View File

@@ -0,0 +1,178 @@
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { Box, Button, Flex, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyModal from '@fastgpt/web/components/common/MyModal';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import { useForm } from 'react-hook-form';
import { appTypeMap } from '@/pageComponents/app/constants';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useMemo } from 'react';
import { getAppType } from '@fastgpt/global/core/app/utils';
import { useContextSelector } from 'use-context-selector';
import { AppListContext } from './context';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { postCreateApp } from '@/web/core/app/api';
import { useRouter } from 'next/router';
import { form2AppWorkflow } from '@/web/core/app/utils';
import ImportAppConfigEditor from '@/pageComponents/app/ImportAppConfigEditor';
type FormType = {
avatar: string;
name: string;
workflowStr: string;
};
const JsonImportModal = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const { parentId, loadMyApps } = useContextSelector(AppListContext, (v) => v);
const router = useRouter();
const { register, setValue, watch, handleSubmit } = useForm<FormType>({
defaultValues: {
avatar: '',
name: '',
workflowStr: ''
}
});
const workflowStr = watch('workflowStr');
const avatar = watch('avatar');
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
// If the user does not select an avatar, it will follow the type to change
const selectedAvatar = useMemo(() => {
if (avatar) return avatar;
const defaultVal = appTypeMap[AppTypeEnum.simple].avatar;
if (!workflowStr) return defaultVal;
try {
const workflow = JSON.parse(workflowStr);
const type = getAppType(workflow);
if (type) return appTypeMap[type].avatar;
return defaultVal;
} catch (err) {
return defaultVal;
}
}, [avatar, workflowStr]);
const { runAsync: onSubmit, loading: isCreating } = useRequest2(
async ({ name, workflowStr }: FormType) => {
const { workflow, appType } = await (async () => {
try {
const workflow = JSON.parse(workflowStr);
const appType = getAppType(workflow);
if (!appType) {
return Promise.reject(t('app:type_not_recognized'));
}
if (appType === AppTypeEnum.simple) {
return {
workflow: form2AppWorkflow(workflow, t),
appType
};
}
return {
workflow,
appType
};
} catch (err) {
return Promise.reject(t('app:invalid_json_format'));
}
})();
return postCreateApp({
parentId,
avatar: selectedAvatar,
name,
type: appType,
modules: workflow.nodes,
edges: workflow.edges,
chatConfig: workflow.chatConfig
});
},
{
refreshDeps: [selectedAvatar],
onSuccess(id: string) {
router.push(`/app/detail?appId=${id}`);
loadMyApps();
onClose();
},
successToast: t('common:common.Create Success')
}
);
return (
<>
<MyModal
isOpen
onClose={onClose}
isLoading={isCreating}
title={t('app:type.Import from json')}
iconSrc="common/importLight"
iconColor={'primary.600'}
>
<ModalBody>
<Box color={'myGray.800'} fontWeight={'bold'}>
{t('common:common.Set Name')}
</Box>
<Flex mt={2} alignItems={'center'}>
<MyTooltip label={t('common:common.Set Avatar')}>
<Avatar
flexShrink={0}
src={selectedAvatar}
w={['1.75rem', '2.25rem']}
h={['1.75rem', '2.25rem']}
cursor={'pointer'}
borderRadius={'md'}
onClick={onOpenSelectFile}
/>
</MyTooltip>
<Input
flex={1}
ml={3}
autoFocus
bg={'myWhite.600'}
{...register('name', {
required: t('common:core.app.error.App name can not be empty')
})}
/>
</Flex>
<Box mt={5}>
<ImportAppConfigEditor
value={workflowStr}
onChange={(e) => setValue('workflowStr', e)}
rows={10}
/>
</Box>
</ModalBody>
<ModalFooter gap={4}>
<Button variant={'whiteBase'} onClick={onClose}>
{t('common:common.Cancel')}
</Button>
<Button onClick={handleSubmit(onSubmit)}>{t('common:common.Confirm')}</Button>
</ModalFooter>
</MyModal>
<File
onSelect={(e) =>
onSelectImage(e, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
</>
);
};
export default JsonImportModal;

View File

@@ -37,6 +37,7 @@ import { webPushTrack } from '@/web/common/middle/tracks/utils';
import { AppTemplateSchemaType, TemplateTypeSchemaType } from '@fastgpt/global/core/app/type';
import { i18nT } from '@fastgpt/web/i18n/utils';
import UseGuideModal from '@/components/common/Modal/UseGuideModal';
import { form2AppWorkflow } from '@/web/core/app/utils';
type TemplateAppType = AppTypeEnum | 'all';
@@ -94,14 +95,18 @@ const TemplateMarketModal = ({
const { runAsync: onUseTemplate, loading: isCreating } = useRequest2(
async (template: AppTemplateSchemaType) => {
const templateDetail = await getTemplateMarketItemDetail(template.templateId);
let workflow = templateDetail.workflow;
if (templateDetail.type === AppTypeEnum.simple) {
workflow = form2AppWorkflow(workflow, t);
}
return postCreateApp({
parentId,
avatar: template.avatar,
name: template.name,
type: template.type as AppTypeEnum,
modules: templateDetail.workflow.nodes || [],
edges: templateDetail.workflow.edges || [],
chatConfig: templateDetail.workflow.chatConfig
modules: workflow.nodes || [],
edges: workflow.edges || [],
chatConfig: workflow.chatConfig
}).then((res) => {
webPushTrack.useAppTemplate({
id: res,

View File

@@ -30,6 +30,7 @@ import { useSystem } from '@fastgpt/web/hooks/useSystem';
import MyIcon from '@fastgpt/web/components/common/Icon';
import TemplateMarketModal from './components/TemplateMarketModal';
import MyImage from '@fastgpt/web/components/common/Image/MyImage';
import JsonImportModal from './components/JsonImportModal';
const CreateModal = dynamic(() => import('./components/CreateModal'));
const EditFolderModal = dynamic(
@@ -64,6 +65,11 @@ const MyApps = () => {
onOpen: onOpenCreateHttpPlugin,
onClose: onCloseCreateHttpPlugin
} = useDisclosure();
const {
isOpen: isOpenJsonImportModal,
onOpen: onOpenJsonImportModal,
onClose: onCloseJsonImportModal
} = useDisclosure();
const [editFolder, setEditFolder] = useState<EditFolderFormType>();
const [templateModalType, setTemplateModalType] = useState<AppTypeEnum | 'all'>();
@@ -244,6 +250,16 @@ const MyApps = () => {
}
]
},
{
children: [
{
icon: 'core/app/type/jsonImport',
label: t('app:type.Import from json'),
description: t('app:type.Import from json tip'),
onClick: onOpenJsonImportModal
}
]
},
...(isPc
? []
: [
@@ -375,6 +391,7 @@ const MyApps = () => {
defaultType={templateModalType}
/>
)}
{isOpenJsonImportModal && <JsonImportModal onClose={onCloseJsonImportModal} />}
</Flex>
);
};

View File

@@ -74,7 +74,6 @@ export const useSelectFile = (props?: {
maxW,
maxH
});
console.log(src, '--');
callback?.(src);
return src;
} catch (err: any) {

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import { AppItemType } from '@/types/app';
import { parseCurl } from '@fastgpt/global/common/string/http';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { AppSchema } from '@fastgpt/global/core/app/type';
import {
@@ -408,3 +409,286 @@ export const emptyTemplates: Record<
edges: []
}
};
export const parsePluginFromCurlString = (
curl: string
): {
nodes: AppSchema['modules'];
edges: AppSchema['edges'];
chatConfig: AppSchema['chatConfig'];
} => {
const { url, method, headers, body } = parseCurl(curl);
return {
nodes: [
{
nodeId: 'pluginInput',
name: 'workflow:template.plugin_start',
intro: 'workflow:intro_plugin_input',
avatar: 'core/workflow/template/workflowStart',
flowNodeType: 'pluginInput',
showStatus: false,
position: {
x: 630.1191328382079,
y: -125.05298493910118
},
version: '481',
inputs: [],
outputs: []
},
{
nodeId: 'pluginOutput',
name: 'common:core.module.template.self_output',
intro: 'workflow:intro_custom_plugin_output',
avatar: 'core/workflow/template/pluginOutput',
flowNodeType: 'pluginOutput',
showStatus: false,
position: {
x: 1776.334576378706,
y: -179.2671413906911
},
version: '481',
inputs: [
{
renderTypeList: ['reference'],
valueType: 'any',
canEdit: true,
key: 'result',
label: 'result',
isToolOutput: false,
description: '',
required: true,
value: ['vumlECDQTjeC', 'httpRawResponse']
},
{
renderTypeList: ['reference'],
valueType: 'object',
canEdit: true,
key: 'error',
label: 'error',
isToolOutput: false,
description: '',
required: true,
value: ['vumlECDQTjeC', 'error']
}
],
outputs: []
},
{
nodeId: 'vumlECDQTjeC',
name: 'HTTP 请求',
intro: '可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)',
avatar: 'core/workflow/template/httpRequest',
flowNodeType: 'httpRequest468',
showStatus: true,
position: {
x: 1068.6226695001628,
y: -435.2671413906911
},
version: '481',
inputs: [
{
key: 'system_addInputParam',
renderTypeList: ['addInputParam'],
valueType: 'dynamic',
label: '',
required: false,
description: '接收前方节点的输出值作为变量,这些变量可以被 HTTP 请求参数使用。',
customInputConfig: {
selectValueTypeList: [
'string',
'number',
'boolean',
'object',
'arrayString',
'arrayNumber',
'arrayBoolean',
'arrayObject',
'arrayAny',
'any',
'chatHistory',
'datasetQuote',
'dynamic',
'selectApp',
'selectDataset'
],
showDescription: false,
showDefaultValue: true
},
valueDesc: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpMethod',
renderTypeList: ['custom'],
valueType: 'string',
label: '',
value: method,
required: true,
valueDesc: '',
description: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpTimeout',
renderTypeList: ['custom'],
valueType: 'number',
label: '',
value: 30,
min: 5,
max: 600,
required: true,
valueDesc: '',
description: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpReqUrl',
renderTypeList: ['hidden'],
valueType: 'string',
label: '',
description:
'新的 HTTP 请求地址。如果出现两个"请求地址",可以删除该模块重新加入,会拉取最新的模块配置。',
placeholder: 'https://api.ai.com/getInventory',
required: false,
value: url,
valueDesc: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpHeader',
renderTypeList: ['custom'],
valueType: 'any',
value: headers,
label: '',
description:
'自定义请求头,请严格填入 JSON 字符串。\n1. 确保最后一个属性没有逗号\n2. 确保 key 包含双引号\n例如{"Authorization":"Bearer xxx"}',
placeholder: 'common:core.module.input.description.Http Request Header',
required: false,
valueDesc: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpParams',
renderTypeList: ['hidden'],
valueType: 'any',
description:
'新的 HTTP 请求地址。如果出现两个“请求地址”,可以删除该模块重新加入,会拉取最新的模块配置。',
label: '',
required: false,
valueDesc: '',
description: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpJsonBody',
renderTypeList: ['hidden'],
valueType: 'any',
value: body,
label: '',
required: false,
valueDesc: '',
description: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpFormBody',
renderTypeList: ['hidden'],
valueType: 'any',
value: [],
label: '',
required: false,
valueDesc: '',
description: '',
debugLabel: '',
toolDescription: ''
},
{
key: 'system_httpContentType',
renderTypeList: ['hidden'],
valueType: 'string',
value: 'json',
label: '',
required: false,
valueDesc: '',
description: '',
debugLabel: '',
toolDescription: ''
}
],
outputs: [
{
id: 'system_addOutputParam',
key: 'system_addOutputParam',
type: 'dynamic',
valueType: 'dynamic',
label: '输出字段提取',
customFieldConfig: {
selectValueTypeList: [
'string',
'number',
'boolean',
'object',
'arrayString',
'arrayNumber',
'arrayBoolean',
'arrayObject',
'arrayAny',
'any',
'chatHistory',
'datasetQuote',
'dynamic',
'selectApp',
'selectDataset'
],
showDescription: false,
showDefaultValue: false
},
description: '可以通过 JSONPath 语法来提取响应值中的指定字段',
valueDesc: ''
},
{
id: 'error',
key: 'error',
label: '请求错误',
description: 'HTTP请求错误信息成功时返回空',
valueType: 'object',
type: 'static',
valueDesc: ''
},
{
id: 'httpRawResponse',
key: 'httpRawResponse',
required: true,
label: '原始响应',
description: 'HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。',
valueType: 'any',
type: 'static',
valueDesc: ''
}
]
}
],
edges: [
{
source: 'pluginInput',
target: 'vumlECDQTjeC',
sourceHandle: 'pluginInput-source-right',
targetHandle: 'vumlECDQTjeC-target-left'
},
{
source: 'vumlECDQTjeC',
target: 'pluginOutput',
sourceHandle: 'vumlECDQTjeC-source-right',
targetHandle: 'pluginOutput-target-left'
}
]
};
};

View File

@@ -40,6 +40,7 @@ import {
Input_Template_UserChatInput
} from '@fastgpt/global/core/workflow/template/input';
import { workflowStartNodeId } from './constants';
import { getDefaultAppForm } from '@fastgpt/global/core/app/utils';
type WorkflowType = {
nodes: StoreNodeItemType[];
@@ -515,6 +516,13 @@ export function form2AppWorkflow(
chatConfig: data.chatConfig
};
}
export function filterSensitiveFormData(appForm: AppSimpleEditFormType) {
const defaultAppForm = getDefaultAppForm();
return {
...appForm,
dataset: defaultAppForm.dataset
};
}
export const workflowSystemVariables: EditorVariablePickerType[] = [
{