V4.9.6 feature (#4565)

* Dashboard submenu (#4545)

* add app submenu (#4452)

* add app submenu

* fix

* width & i18n

* optimize submenu code (#4515)

* optimize submenu code

* fix

* fix

* fix

* fix ts

* perf: dashboard sub menu

* doc

---------

Co-authored-by: heheer <heheer@sealos.io>

* feat: value format test

* doc

* Mcp export (#4555)

* feat: mcp server

* feat: mcp server

* feat: mcp server build

* update doc

* perf: path selector (#4556)

* perf: path selector

* fix: docker file path

* perf: add image endpoint to dataset search (#4557)

* perf: add image endpoint to dataset search

* fix: mcp_server url

* human in loop (#4558)

* Support interactive nodes for loops, and enhance the function of merging nested and loop node history messages. (#4552)

* feat: add LoopInteractive definition

* feat: Support LoopInteractive type and update related logic

* fix: Refactor loop handling logic and improve output value initialization

* feat: Add mergeSignId to dispatchLoop and dispatchRunAppNode responses

* feat: Enhance mergeChatResponseData to recursively merge plugin details and improve response handling

* refactor: Remove redundant comments in mergeChatResponseData for clarity

* perf: loop interactive

* perf: human in loop

---------

Co-authored-by: Theresa <63280168+sd0ric4@users.noreply.github.com>

* mcp server ui

* integrate mcp (#4549)

* integrate mcp

* delete unused code

* fix ts

* bug fix

* fix

* support whole mcp tools

* add try catch

* fix

* fix

* fix ts

* fix test

* fix ts

* fix: interactive in v1 completions

* doc

* fix: router path

* fix mcp integrate (#4563)

* fix mcp integrate

* fix ui

* fix: mcp ux

* feat: mcp call title

* remove repeat loading

* fix mcp tools avatar (#4564)

* fix

* fix avatar

* fix update version

* update doc

* fix: value format

* close server and remove cache

* perf: avatar

---------

Co-authored-by: heheer <heheer@sealos.io>
Co-authored-by: Theresa <63280168+sd0ric4@users.noreply.github.com>
This commit is contained in:
Archer
2025-04-16 22:18:51 +08:00
committed by GitHub
parent ab799e13cd
commit 952412f648
166 changed files with 6318 additions and 1263 deletions

View File

@@ -0,0 +1,339 @@
import { Box, Flex, useDisclosure } from '@chakra-ui/react';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { AppTemplateTypeEnum, AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useRouter } from 'next/router';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { navbarWidth } from '@/components/Layout';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getTemplateMarketItemList, getTemplateTagList } from '@/web/core/app/api/template';
import { AppTemplateSchemaType, TemplateTypeSchemaType } from '@fastgpt/global/core/app/type';
import { getPluginGroups } from '@/web/core/app/api/plugin';
import { PluginGroupSchemaType } from '@fastgpt/service/core/app/plugin/type';
export enum TabEnum {
apps = 'apps',
app_templates = 'templateMarket',
mcp_server = 'mcpServer'
}
type TabEnumType = `${keyof typeof TabEnum}` | string;
const DashboardContainer = ({
children
}: {
children: (e: {
templateTags: TemplateTypeSchemaType[];
templateList: AppTemplateSchemaType[];
pluginGroups: PluginGroupSchemaType[];
MenuIcon: JSX.Element;
}) => React.ReactNode;
}) => {
const router = useRouter();
const { t } = useTranslation();
const { isPc } = useSystem();
const { feConfigs } = useSystemStore();
const { isOpen: isOpenSidebar, onOpen: onOpenSidebar, onClose: onCloseSidebar } = useDisclosure();
// First tab
const currentTab = useMemo(() => {
const path = router.asPath.split('?')[0]; // 移除查询参数
const segments = path.split('/').filter(Boolean); // 过滤空字符串
return (segments.pop() as TabEnumType) || TabEnum.apps;
}, [router.asPath]);
// Sub tab
const { type: currentType, appType } = router.query as {
type: string;
appType?: AppTypeEnum | 'all';
};
// Template market
const { data: templateTags = [], loading: isLoadingTemplatesTags } = useRequest2(
() =>
currentTab === TabEnum.app_templates
? getTemplateTagList().then((res) => [
{
typeId: AppTemplateTypeEnum.recommendation,
typeName: t('app:templateMarket.templateTags.Recommendation'),
typeOrder: 0
},
...res
])
: Promise.resolve([]),
{
manual: false,
refreshDeps: [currentTab]
}
);
const { data: templateList = [], loading: isLoadingTemplates } = useRequest2(
() =>
currentTab === TabEnum.app_templates
? getTemplateMarketItemList({ type: appType })
: Promise.resolve([]),
{
manual: false,
refreshDeps: [currentTab, appType]
}
);
// System tools
const { data: pluginGroups = [], loading: isLoadingToolGroups } = useRequest2(
() =>
getPluginGroups().then((res) =>
res.map((item) => ({
...item,
groupTypes: [
{
typeId: 'all',
typeName: t('app:type.All')
},
...item.groupTypes
]
}))
),
{
manual: false
}
);
const groupList = useMemo<
{
groupId: string;
groupAvatar: string;
groupName: string;
children: {
typeId: string;
typeName: string;
isActive?: boolean;
onClick?: () => void;
}[];
}[]
>(() => {
return [
{
groupId: TabEnum.apps,
groupAvatar: 'common/app',
groupName: t('common:core.module.template.Team app'),
children: [
{
isActive: !currentType,
typeId: 'all',
typeName: t('app:type.All')
},
{
typeId: AppTypeEnum.simple,
typeName: t('app:type.Simple bot')
},
{
typeId: AppTypeEnum.workflow,
typeName: t('app:type.Workflow bot')
},
{
typeId: AppTypeEnum.plugin,
typeName: t('app:type.Plugin')
}
]
},
...pluginGroups.map((group) => ({
groupId: group.groupId,
groupAvatar: group.groupAvatar,
groupName: t(group.groupName as any),
children: group.groupTypes.map((type, index) => ({
typeId: type.typeId,
typeName: t(type.typeName as any),
isActive: index === 0 && !currentType
}))
})),
{
groupId: TabEnum.app_templates,
groupAvatar: 'common/templateMarket',
groupName: t('common:template_market'),
children: [
...templateTags
.map((tag) => {
const templates = templateList.filter((template) =>
template.tags.includes(tag.typeId)
);
return {
...tag,
templates
};
})
.filter((tag) => tag.templates.length > 0)
.map((tag, index) => ({
typeId: tag.typeId,
typeName: t(tag.typeName as any),
isActive: index === 0 && !currentType
})),
...(feConfigs?.appTemplateCourse
? [
{
typeId: AppTemplateTypeEnum.contribute,
typeName: t('common:contribute_app_template'),
onClick: () => {
window.open(feConfigs.appTemplateCourse);
}
}
]
: [])
]
},
...(feConfigs?.mcpServerProxyEndpoint
? [
{
groupId: TabEnum.mcp_server,
groupAvatar: 'key',
groupName: t('common:mcp_server'),
children: []
}
]
: [])
];
}, [currentType, feConfigs.appTemplateCourse, pluginGroups, t, templateList, templateTags]);
const MenuIcon = useMemo(
() => (
<Flex alignItems={'center'}>
{isOpenSidebar && (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
onClick={onCloseSidebar}
zIndex={99}
/>
)}
<MyIcon name="menu" w={'1.25rem'} mr={1.5} onClick={onOpenSidebar} />
</Flex>
),
[isOpenSidebar, onCloseSidebar, onOpenSidebar]
);
const isLoading = isLoadingTemplatesTags || isLoadingTemplates || isLoadingToolGroups;
return (
<Box h={'100%'}>
{/* Side bar */}
{(isPc || isOpenSidebar) && (
<MyBox
isLoading={isLoading}
position={'fixed'}
left={isPc ? navbarWidth : 0}
top={0}
bg={'myGray.25'}
w={`220px`}
h={'full'}
borderLeft={'1px solid'}
borderRight={'1px solid'}
borderColor={'myGray.200'}
pt={4}
px={2.5}
pb={2.5}
zIndex={100}
userSelect={'none'}
>
{groupList.map((group) => {
const selected = currentTab === group.groupId;
return (
<Box key={group.groupId}>
<Flex
p={2}
fontSize={'sm'}
rounded={'md'}
color={'myGray.700'}
cursor={'pointer'}
_hover={{
bg: 'primary.50'
}}
mb={0.5}
onClick={() => {
router.push(`/dashboard/${group.groupId}`);
onCloseSidebar();
}}
{...(group.children.length === 0 &&
selected && { bg: 'primary.100', color: 'primary.600' })}
>
<Avatar src={group.groupAvatar} w={'1rem'} mr={1.5} />
<Box fontWeight={'medium'}>{group.groupName}</Box>
<Box flex={1} />
{group.children.length > 0 && (
<MyIcon
name={selected ? 'core/chat/chevronDown' : 'core/chat/chevronUp'}
w={'1rem'}
/>
)}
</Flex>
{selected && (
<Box>
{group.children.map((child) => {
const isActive = child.isActive || child.typeId === currentType;
return (
<Flex
key={child.typeId}
fontSize={'sm'}
fontWeight={500}
rounded={'md'}
py={2}
pl={'30px'}
cursor={'pointer'}
mb={0.5}
_hover={{ bg: 'primary.50' }}
{...(isActive
? {
bg: 'primary.50',
color: 'primary.600'
}
: {
bg: 'transparent',
color: 'myGray.500'
})}
onClick={() => {
if (child.onClick) {
child.onClick();
} else {
router.push({
query: {
...router.query,
type: child.typeId
}
});
onCloseSidebar();
}
}}
>
{child.typeName}
</Flex>
);
})}
</Box>
)}
</Box>
);
})}
</MyBox>
)}
<Box h={'100%'} pl={isPc ? `220px` : 0} position={'relative'}>
{children({
templateTags,
templateList,
pluginGroups,
MenuIcon
})}
</Box>
</Box>
);
};
export default DashboardContainer;

View File

@@ -0,0 +1,111 @@
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { Box, Flex, HStack } from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyBox from '@fastgpt/web/components/common/MyBox';
import React from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node';
import { PluginGroupSchemaType } from '@fastgpt/service/core/app/plugin/type';
import UseGuideModal from '@/components/common/Modal/UseGuideModal';
const PluginCard = ({
item,
groups
}: {
item: NodeTemplateListItemType;
groups: PluginGroupSchemaType[];
}) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const type = groups.reduce<string | undefined>((acc, group) => {
const foundType = group.groupTypes.find((type) => type.typeId === item.templateType);
return foundType ? foundType.typeName : acc;
}, undefined);
return (
<MyBox
key={item.id}
lineHeight={1.5}
h="100%"
pt={4}
pb={3}
px={4}
border={'base'}
boxShadow={'2'}
bg={'white'}
borderRadius={'10px'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5'
}}
>
<HStack>
<Avatar src={item.avatar} borderRadius={'sm'} w={'1.5rem'} h={'1.5rem'} />
<Box flex={'1 0 0'} color={'myGray.900'} fontWeight={500}>
{item.name}
</Box>
<Box mr={'-1rem'}>
<Flex
bg={'myGray.100'}
color={'myGray.600'}
py={0.5}
pl={2}
pr={3}
borderLeftRadius={'sm'}
whiteSpace={'nowrap'}
>
<Box ml={1} fontSize={'mini'}>
{t(type as any)}
</Box>
</Flex>
</Box>
</HStack>
<Box
flex={['1 0 48px', '1 0 56px']}
mt={3}
pr={1}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'}>{item.intro || t('app:templateMarket.no_intro')}</Box>
</Box>
<Flex w={'full'} fontSize={'mini'}>
<Flex flex={1}>
{(item.instructions || item.courseUrl) && (
<UseGuideModal
title={item.name}
iconSrc={item.avatar}
text={item.instructions}
link={item.courseUrl}
>
{({ onClick }) => (
<Flex
color={'primary.700'}
alignItems={'center'}
gap={1}
cursor={'pointer'}
onClick={onClick}
_hover={{ bg: 'myGray.100' }}
>
<MyIcon name={'book'} w={'14px'} />
{t('app:plugin.Instructions')}
</Flex>
)}
</UseGuideModal>
)}
</Flex>
<Box color={'myGray.500'}>{`by ${item.author || feConfigs.systemTitle}`}</Box>
</Flex>
</MyBox>
);
};
export default React.memo(PluginCard);

View File

@@ -0,0 +1,336 @@
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, 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';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation } from 'next-i18next';
import { useContextSelector } from 'use-context-selector';
import { AppListContext } from './context';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { ChevronRightIcon } from '@chakra-ui/icons';
import MyIcon from '@fastgpt/web/components/common/Icon';
import {
getTemplateMarketItemDetail,
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;
const CreateModal = ({ onClose, type }: { type: CreateAppType; onClose: () => void }) => {
const { t } = useTranslation();
const router = useRouter();
const { parentId, loadMyApps } = useContextSelector(AppListContext, (v) => v);
const { isPc } = useSystem();
const { feConfigs } = useSystemStore();
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 }),
{
manual: false
}
);
const { register, setValue, watch, handleSubmit } = useForm<FormType>({
defaultValues: {
avatar: typeData.avatar,
name: '',
curlContent: ''
}
});
const avatar = watch('avatar');
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: '.jpg,.png',
multiple: false
});
const { runAsync: onclickCreate, loading: isCreating } = useRequest2(
async ({ avatar, name, curlContent }: FormType, templateId?: string) => {
// From empty template
if (!templateId && currentCreateType !== 'curl') {
return postCreateApp({
parentId,
avatar: avatar,
name: name,
type,
modules: emptyTemplates[type].nodes,
edges: emptyTemplates[type].edges,
chatConfig: emptyTemplates[type].chatConfig
});
}
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: appAvatar,
name: name,
type,
modules: workflow.nodes || [],
edges: workflow.edges || [],
chatConfig: workflow.chatConfig || {}
});
},
{
onSuccess(id: string) {
router.push(`/app/detail?appId=${id}`);
loadMyApps();
onClose();
},
successToast: t('common:common.Create Success'),
errorToast: t('common:common.Create Failed')
}
);
return (
<MyModal
iconSrc={typeData.icon}
title={t(typeData.title)}
isOpen
isCentered={!isPc}
maxW={['90vw', '40rem']}
isLoading={isCreating || isRequestTemplates}
>
<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={avatar}
w={['28px', '36px']}
h={['28px', '36px']}
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>
<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>
)}
<Box flex={1} />
{isTemplateMode && (
<Flex
onClick={() => {
router.push({
pathname: '/dashboard/templateMarket',
query: {
appType: type,
parentId
}
});
onClose();
}}
alignItems={'center'}
cursor={'pointer'}
color={'myGray.600'}
fontSize={'xs'}
_hover={{ color: 'blue.700' }}
>
{t('common:core.app.switch_to_template_market')}
<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
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}>
{t(typeData.emptyCreateText)}
</Box>
</Card>
{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, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
</MyModal>
);
};
export default CreateModal;

View File

@@ -0,0 +1,469 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Flex,
Button,
ModalBody,
Input,
Textarea,
TableContainer,
Table,
Thead,
Th,
Tbody,
Tr,
Td,
ModalFooter
} from '@chakra-ui/react';
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { useForm } from 'react-hook-form';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useTranslation } from 'next-i18next';
import { HttpPluginImgUrl } from '@fastgpt/global/common/file/image/constants';
import {
postCreateHttpPlugin,
putUpdateHttpPlugin,
getApiSchemaByUrl
} from '@/web/core/app/api/plugin';
import { str2OpenApiSchema } from '@fastgpt/global/core/app/httpPlugin/utils';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyModal from '@fastgpt/web/components/common/MyModal';
import HttpInput from '@fastgpt/web/components/common/Input/HttpInput';
import { OpenApiJsonSchema } from '@fastgpt/global/core/app/httpPlugin/type';
import { AppSchema } from '@fastgpt/global/core/app/type';
import { useContextSelector } from 'use-context-selector';
import { AppListContext } from './context';
export type EditHttpPluginProps = {
id?: string;
avatar: string;
name: string;
intro?: string;
pluginData?: AppSchema['pluginData'];
};
export const defaultHttpPlugin: EditHttpPluginProps = {
avatar: HttpPluginImgUrl,
name: '',
intro: '',
pluginData: {
apiSchemaStr: '',
customHeaders: '{"Authorization":"Bearer"}'
}
};
const HttpPluginEditModal = ({
defaultPlugin = defaultHttpPlugin,
onClose
}: {
defaultPlugin?: EditHttpPluginProps;
onClose: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const isEdit = !!defaultPlugin.id;
const { parentId, loadMyApps } = useContextSelector(AppListContext, (v) => v);
const [schemaUrl, setSchemaUrl] = useState('');
const [customHeaders, setCustomHeaders] = useState<{ key: string; value: string }[]>(() => {
const keyValue = JSON.parse(defaultPlugin.pluginData?.customHeaders || '{}');
return Object.keys(keyValue).map((key) => ({ key, value: keyValue[key] }));
});
const [updateTrigger, setUpdateTrigger] = useState(false);
const { register, setValue, handleSubmit, watch } = useForm<EditHttpPluginProps>({
defaultValues: defaultPlugin
});
const avatar = watch('avatar');
const apiSchemaStr = watch('pluginData.apiSchemaStr');
const [apiData, setApiData] = useState<OpenApiJsonSchema>({ pathData: [], serverPath: '' });
const { mutate: onCreate, isLoading: isCreating } = useRequest({
mutationFn: async (data: EditHttpPluginProps) => {
return postCreateHttpPlugin({
parentId,
name: data.name,
intro: data.intro,
avatar: data.avatar,
pluginData: {
apiSchemaStr: data.pluginData?.apiSchemaStr,
customHeaders: data.pluginData?.customHeaders
}
});
},
onSuccess() {
loadMyApps();
onClose();
},
successToast: t('common:common.Create Success'),
errorToast: t('common:common.Create Failed')
});
const { mutate: updatePlugins, isLoading: isUpdating } = useRequest({
mutationFn: async (data: EditHttpPluginProps) => {
if (!data.id || !data.pluginData) return Promise.resolve('');
return putUpdateHttpPlugin({
appId: data.id,
name: data.name,
intro: data.intro,
avatar: data.avatar,
pluginData: data.pluginData
});
},
onSuccess() {
loadMyApps();
onClose();
},
successToast: t('common:common.Update Success'),
errorToast: t('common:common.Update Failed')
});
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: 'image/*',
multiple: false
});
/* load api from url */
const { mutate: onClickUrlLoadApi, isLoading: isLoadingUrlApi } = useRequest({
mutationFn: async () => {
if (!schemaUrl || (!schemaUrl.startsWith('https://') && !schemaUrl.startsWith('http://'))) {
return toast({
title: t('common:plugin.Invalid URL'),
status: 'warning'
});
}
const schema = await getApiSchemaByUrl(schemaUrl);
setValue('pluginData.apiSchemaStr', JSON.stringify(schema, null, 2));
},
errorToast: t('common:plugin.Invalid Schema')
});
useEffect(() => {
(async () => {
if (!apiSchemaStr) {
return setApiData({ pathData: [], serverPath: '' });
}
try {
setApiData(await str2OpenApiSchema(apiSchemaStr));
} catch (err) {
toast({
status: 'warning',
title: t('common:plugin.Invalid Schema')
});
setApiData({ pathData: [], serverPath: '' });
}
})();
}, [apiSchemaStr, t, toast]);
return (
<>
<MyModal
isOpen
onClose={onClose}
iconSrc="core/app/type/httpPluginFill"
title={isEdit ? t('common:plugin.Edit Http Plugin') : t('common:plugin.Import Plugin')}
w={['90vw', '600px']}
h={['90vh', '80vh']}
position={'relative'}
>
<ModalBody flex={'1 0 0'} overflow={'auto'}>
<>
<Box color={'myGray.800'} fontWeight={'bold'}>
{t('common:plugin.Set Name')}
</Box>
<Flex mt={3} alignItems={'center'}>
<MyTooltip label={t('common:common.Set Avatar')}>
<Avatar
flexShrink={0}
src={avatar}
w={['28px', '32px']}
h={['28px', '32px']}
cursor={'pointer'}
borderRadius={'md'}
onClick={onOpenSelectFile}
/>
</MyTooltip>
<Input
flex={1}
ml={4}
bg={'myWhite.600'}
{...register('name', {
required: t('common:common.name_is_empty')
})}
/>
</Flex>
<>
<Box color={'myGray.800'} fontWeight={'bold'} mt={3}>
{t('common:plugin.Intro')}
</Box>
<Textarea
{...register('intro')}
bg={'myWhite.600'}
rows={3}
mt={3}
placeholder={t('common:core.plugin.Http plugin intro placeholder')}
/>
</>
</>
{/* import */}
<Box mt={4}>
<Box
color={'myGray.800'}
fontWeight={'bold'}
justifyContent={'space-between'}
display={'flex'}
>
<Box my={'auto'}>{'OpenAPI Schema'}</Box>
<Box>
<Flex alignItems={'center'}>
<Input
mr={2}
placeholder={t('common:plugin.Import from URL')}
h={'30px'}
w={['150px', '250px']}
fontSize={'sm'}
onBlur={(e) => setSchemaUrl(e.target.value)}
/>
<Button
size={'sm'}
variant={'whitePrimary'}
isLoading={isLoadingUrlApi}
onClick={onClickUrlLoadApi}
>
{t('common:common.Import')}
</Button>
</Flex>
</Box>
</Box>
<Textarea
{...register('pluginData.apiSchemaStr')}
bg={'myWhite.600'}
rows={10}
mt={3}
onBlur={(e) => {
const content = e.target.value;
if (!content) return;
setValue('pluginData.apiSchemaStr', content);
}}
/>
</Box>
<>
<Box color={'myGray.800'} fontWeight={'bold'} mt={3}>
{t('common:core.plugin.Custom headers')}
</Box>
<Box
mt={1}
borderRadius={'md'}
overflow={'hidden'}
borderWidth={'1px'}
borderBottom={'none'}
>
<TableContainer overflowY={'visible'} overflowX={'unset'}>
<Table>
<Thead>
<Tr>
<Th px={2} borderRadius="none !important">
{t('common:core.module.http.Props name')}
</Th>
<Th px={2} borderRadius="none !important">
{t('common:core.module.http.Props value')}
</Th>
</Tr>
</Thead>
<Tbody>
{customHeaders.map((item, index) => (
<Tr key={`${index}`}>
<Td p={0} w={'150px'}>
<HttpInput
placeholder={t('common:core.module.http.Props name')}
value={item.key}
onBlur={(val) => {
setCustomHeaders((prev) => {
const newHeaders = prev.map((item, i) =>
i === index ? { ...item, key: val } : item
);
setValue(
'pluginData.customHeaders',
'{\n' +
newHeaders
.map((item) => `"${item.key}":"${item.value}"`)
.join(',\n') +
'\n}'
);
return newHeaders;
});
}}
updateTrigger={updateTrigger}
/>
</Td>
<Td p={0}>
<Box display={'flex'} alignItems={'center'}>
<HttpInput
placeholder={t('common:core.module.http.Props value')}
value={item.value}
onBlur={(val) =>
setCustomHeaders((prev) => {
const newHeaders = prev.map((item, i) =>
i === index ? { ...item, value: val } : item
);
setValue(
'pluginData.customHeaders',
'{\n' +
newHeaders
.map((item) => `"${item.key}":"${item.value}"`)
.join(',\n') +
'\n}'
);
return newHeaders;
})
}
/>
<MyIcon
name={'delete'}
cursor={'pointer'}
_hover={{ color: 'red.600' }}
w={'14px'}
onClick={() =>
setCustomHeaders((prev) => {
const newHeaders = prev.filter((val) => val.key !== item.key);
setValue(
'pluginData.customHeaders',
'{\n' +
newHeaders
.map((item) => `"${item.key}":"${item.value}"`)
.join(',\n') +
'\n}'
);
return newHeaders;
})
}
/>
</Box>
</Td>
</Tr>
))}
<Tr>
<Td p={0} w={'150px'}>
<HttpInput
placeholder={t('common:core.module.http.Add props')}
value={''}
updateTrigger={updateTrigger}
onBlur={(val) => {
if (!val) return;
setCustomHeaders((prev) => {
const newHeaders = [...prev, { key: val, value: '' }];
setValue(
'pluginData.customHeaders',
'{\n' +
newHeaders
.map((item) => `"${item.key}":"${item.value}"`)
.join(',\n') +
'\n}'
);
return newHeaders;
});
setUpdateTrigger((prev) => !prev);
}}
/>
</Td>
<Td p={0}>
<Box display={'flex'} alignItems={'center'}>
<HttpInput />
</Box>
</Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
</Box>
</>
<>
<Box color={'myGray.800'} fontWeight={'bold'} mt={3}>
{t('common:plugin.Plugin List')}
</Box>
<Box
mt={3}
borderRadius={'md'}
overflow={'hidden'}
borderWidth={'1px'}
borderBottom={'none'}
>
<TableContainer maxH={400} overflowY={'auto'}>
<Table bg={'white'}>
<Thead bg={'myGray.50'}>
<Th>{t('common:Name')}</Th>
<Th>{t('common:plugin.Description')}</Th>
<Th>{t('common:plugin.Method')}</Th>
<Th>{t('common:plugin.Path')}</Th>
</Thead>
<Tbody>
{apiData.pathData?.map((item, index) => (
<Tr key={index}>
<Td>{item.name}</Td>
<Td
fontSize={'sm'}
textColor={'gray.600'}
w={'auto'}
maxW={80}
whiteSpace={'pre-wrap'}
>
{item.description}
</Td>
<Td>{item.method}</Td>
<Td>{item.path}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
</>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={3} onClick={onClose}>
{t('common:common.Close')}
</Button>
{!isEdit ? (
<Button
isDisabled={apiData.pathData.length === 0}
onClick={handleSubmit((data) => onCreate(data))}
isLoading={isCreating}
>
{t('common:common.Confirm Create')}
</Button>
) : (
<Button
isDisabled={apiData.pathData.length === 0}
isLoading={isUpdating}
onClick={handleSubmit((data) => updatePlugins(data))}
>
{t('common:common.Confirm Update')}
</Button>
)}
</ModalFooter>
</MyModal>
<File
onSelect={(e) =>
onSelectImage(e, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
</>
);
};
export default HttpPluginEditModal;

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

@@ -0,0 +1,468 @@
import React, { useMemo, useState } from 'react';
import { Box, Grid, Flex, IconButton, HStack } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { delAppById, putAppById, resumeInheritPer, changeOwner } from '@/web/core/app/api';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
import PermissionIconText from '@/components/support/permission/IconText';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useTranslation } from 'next-i18next';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useContextSelector } from 'use-context-selector';
import { AppListContext } from './context';
import { AppFolderTypeList, AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useFolderDrag } from '@/components/common/folder/useFolderDrag';
import dynamic from 'next/dynamic';
import type { EditResourceInfoFormType } from '@/components/common/Modal/EditResourceModal';
import MyMenu, { MenuItemType } from '@fastgpt/web/components/common/MyMenu';
import { AppPermissionList } from '@fastgpt/global/support/permission/app/constant';
import {
deleteAppCollaborators,
getCollaboratorList,
postUpdateAppCollaborators
} from '@/web/core/app/api/collaborator';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import AppTypeTag from './TypeTag';
const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal'));
const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal'));
import type { EditHttpPluginProps } from './HttpPluginEditModal';
import { postCopyApp } from '@/web/core/app/api/app';
import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import { RequireOnlyOne } from '@fastgpt/global/common/type/utils';
import UserBox from '@fastgpt/web/components/common/UserBox';
import { PermissionValueType } from '@fastgpt/global/support/permission/type';
const HttpEditModal = dynamic(() => import('./HttpPluginEditModal'));
const ListItem = () => {
const { t } = useTranslation();
const router = useRouter();
const { parentId = null } = router.query;
const { isPc } = useSystem();
const { openConfirm: openMoveConfirm, ConfirmModal: MoveConfirmModal } = useConfirm({
type: 'common',
title: t('common:move.confirm'),
content: t('app:move.hint')
});
const { myApps, loadMyApps, onUpdateApp, setMoveAppId, folderDetail } = useContextSelector(
AppListContext,
(v) => v
);
const [editedApp, setEditedApp] = useState<EditResourceInfoFormType>();
const [editHttpPlugin, setEditHttpPlugin] = useState<EditHttpPluginProps>();
const [editPerAppIndex, setEditPerAppIndex] = useState<number>();
const editPerApp = useMemo(
() => (editPerAppIndex !== undefined ? myApps[editPerAppIndex] : undefined),
[editPerAppIndex, myApps]
);
const parentApp = useMemo(() => myApps.find((item) => item._id === parentId), [parentId, myApps]);
const { runAsync: onPutAppById } = useRequest2(putAppById, {
onSuccess() {
loadMyApps();
}
});
const { getBoxProps } = useFolderDrag({
activeStyles: {
borderColor: 'primary.600'
},
onDrop: (dragId: string, targetId: string) => {
openMoveConfirm(async () => onPutAppById(dragId, { parentId: targetId }))();
}
});
const { openConfirm: openConfirmDel, ConfirmModal: DelConfirmModal } = useConfirm({
type: 'delete'
});
const { lastChatAppId, setLastChatAppId } = useChatStore();
const { runAsync: onclickDelApp } = useRequest2(
(id: string) => {
if (id === lastChatAppId) {
setLastChatAppId('');
}
return delAppById(id);
},
{
onSuccess() {
loadMyApps();
},
successToast: t('common:common.Delete Success'),
errorToast: t('common:common.Delete Failed')
}
);
const { openConfirm: openConfirmCopy, ConfirmModal: ConfirmCopyModal } = useConfirm({
content: t('app:confirm_copy_app_tip')
});
const { runAsync: onclickCopy } = useRequest2(postCopyApp, {
onSuccess({ appId }) {
router.push(`/app/detail?appId=${appId}`);
loadMyApps();
},
successToast: t('app:create_copy_success')
});
const { runAsync: onResumeInheritPermission } = useRequest2(
() => {
return resumeInheritPer(editPerApp!._id);
},
{
manual: true,
errorToast: t('common:permission.Resume InheritPermission Failed'),
onSuccess() {
loadMyApps();
}
}
);
return (
<>
<Grid
py={4}
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
>
{myApps.map((app, index) => {
return (
<MyTooltip
key={app._id}
h="100%"
label={
app.type === AppTypeEnum.folder
? t('common:common.folder.Open folder')
: app.permission.hasWritePer
? t('app:edit_app')
: t('app:go_to_chat')
}
>
<MyBox
lineHeight={1.5}
h="100%"
pt={5}
pb={3}
px={5}
cursor={'pointer'}
border={'base'}
boxShadow={'2'}
bg={'white'}
borderRadius={'lg'}
position={'relative'}
display={'flex'}
flexDirection={'column'}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5',
'& .more': {
display: 'flex'
},
'& .time': {
display: ['flex', 'none']
}
}}
onClick={() => {
if (AppFolderTypeList.includes(app.type)) {
router.push({
query: {
...router.query,
parentId: app._id
}
});
} else if (app.permission.hasWritePer) {
router.push(`/app/detail?appId=${app._id}`);
} else {
router.push(`/chat?appId=${app._id}`);
}
}}
{...getBoxProps({
dataId: app._id,
isFolder: app.type === AppTypeEnum.folder
})}
>
<HStack>
<Avatar src={app.avatar} borderRadius={'sm'} w={'1.5rem'} />
<Box flex={'1 0 0'} color={'myGray.900'}>
{app.name}
</Box>
<Box mr={'-1.25rem'}>
<AppTypeTag type={app.type} />
</Box>
</HStack>
<Box
flex={['1 0 60px', '1 0 72px']}
mt={3}
pr={8}
textAlign={'justify'}
wordBreak={'break-all'}
fontSize={'xs'}
color={'myGray.500'}
>
<Box className={'textEllipsis2'} whiteSpace={'pre-wrap'}>
{app.intro || t('common:common.no_intro')}
</Box>
</Box>
<Flex
h={'24px'}
alignItems={'center'}
justifyContent={'space-between'}
fontSize={'mini'}
color={'myGray.500'}
>
<HStack spacing={3.5}>
<UserBox
sourceMember={app.sourceMember}
fontSize="xs"
avatarSize="1rem"
spacing={0.5}
/>
<PermissionIconText
private={app.private}
color={'myGray.500'}
iconColor={'myGray.400'}
w={'0.875rem'}
/>
</HStack>
<HStack>
{isPc && (
<HStack spacing={0.5} className="time">
<MyIcon name={'history'} w={'0.85rem'} color={'myGray.400'} />
<Box color={'myGray.500'}>
{t(formatTimeToChatTime(app.updateTime) as any).replace('#', ':')}
</Box>
</HStack>
)}
{(AppFolderTypeList.includes(app.type)
? app.permission.hasManagePer
: app.permission.hasWritePer) && (
<Box className="more" display={['', 'none']}>
<MyMenu
size={'xs'}
Button={
<IconButton
size={'xsSquare'}
variant={'transparentBase'}
icon={<MyIcon name={'more'} w={'0.875rem'} color={'myGray.500'} />}
aria-label={''}
/>
}
menuList={[
...([AppTypeEnum.simple, AppTypeEnum.workflow].includes(app.type)
? [
{
children: [
{
icon: 'core/chat/chatLight',
type: 'grayBg' as MenuItemType,
label: t('app:go_to_chat'),
onClick: () => {
router.push(`/chat?appId=${app._id}`);
}
}
]
}
]
: []),
...([AppTypeEnum.plugin].includes(app.type)
? [
{
children: [
{
icon: 'core/chat/chatLight',
type: 'grayBg' as MenuItemType,
label: t('app:go_to_run'),
onClick: () => {
router.push(`/chat?appId=${app._id}`);
}
}
]
}
]
: []),
...(app.permission.hasManagePer
? [
{
children: [
{
icon: 'edit',
type: 'grayBg' as MenuItemType,
label: t('common:dataset.Edit Info'),
onClick: () => {
if (app.type === AppTypeEnum.httpPlugin) {
setEditHttpPlugin({
id: app._id,
name: app.name,
avatar: app.avatar,
intro: app.intro,
pluginData: app.pluginData
});
} else {
setEditedApp({
id: app._id,
avatar: app.avatar,
name: app.name,
intro: app.intro
});
}
}
},
...(folderDetail?.type === AppTypeEnum.httpPlugin &&
!(parentApp ? parentApp.permission : app.permission)
.hasManagePer
? []
: [
{
icon: 'common/file/move',
type: 'grayBg' as MenuItemType,
label: t('common:common.folder.Move to'),
onClick: () => setMoveAppId(app._id)
}
]),
...(app.permission.hasManagePer
? [
{
icon: 'key',
type: 'grayBg' as MenuItemType,
label: t('common:permission.Permission'),
onClick: () => setEditPerAppIndex(index)
}
]
: [])
]
}
]
: []),
...(app.type === AppTypeEnum.toolSet ||
app.type === AppTypeEnum.folder ||
app.type === AppTypeEnum.httpPlugin
? []
: [
{
children: [
{
icon: 'copy',
type: 'grayBg' as MenuItemType,
label: t('app:copy_one_app'),
onClick: () =>
openConfirmCopy(() => onclickCopy({ appId: app._id }))()
}
]
}
]),
...(app.permission.isOwner
? [
{
children: [
{
type: 'danger' as 'danger',
icon: 'delete',
label: t('common:common.Delete'),
onClick: () =>
openConfirmDel(
() => onclickDelApp(app._id),
undefined,
app.type === AppTypeEnum.folder
? t('app:confirm_delete_folder_tip')
: t('app:confirm_del_app_tip', { name: app.name })
)()
}
]
}
]
: [])
]}
/>
</Box>
)}
</HStack>
</Flex>
</MyBox>
</MyTooltip>
);
})}
</Grid>
{myApps.length === 0 && <EmptyTip text={t('common:core.app.no_app')} pt={'30vh'} />}
<DelConfirmModal />
<ConfirmCopyModal />
{!!editedApp && (
<EditResourceModal
{...editedApp}
title={t('common:core.app.edit_content')}
onClose={() => {
setEditedApp(undefined);
}}
onEdit={({ id, ...data }) => onUpdateApp(id, data)}
/>
)}
{!!editPerApp && (
<ConfigPerModal
{...(editPerApp.permission.isOwner && {
onChangeOwner: (tmbId: string) =>
changeOwner({
appId: editPerApp._id,
ownerId: tmbId
}).then(() => loadMyApps())
})}
refetchResource={loadMyApps}
hasParent={Boolean(parentId)}
resumeInheritPermission={onResumeInheritPermission}
isInheritPermission={editPerApp.inheritPermission}
avatar={editPerApp.avatar}
name={editPerApp.name}
managePer={{
permission: editPerApp.permission,
onGetCollaboratorList: () => getCollaboratorList(editPerApp._id),
permissionList: AppPermissionList,
onUpdateCollaborators: (props: {
members?: string[];
groups?: string[];
orgs?: string[];
permission: PermissionValueType;
}) =>
postUpdateAppCollaborators({
...props,
appId: editPerApp._id
}),
onDelOneCollaborator: async (
props: RequireOnlyOne<{
tmbId?: string;
groupId?: string;
orgId?: string;
}>
) =>
deleteAppCollaborators({
...props,
appId: editPerApp._id
}),
refreshDeps: [editPerApp.inheritPermission]
}}
onClose={() => setEditPerAppIndex(undefined)}
/>
)}
{!!editHttpPlugin && (
<HttpEditModal
defaultPlugin={editHttpPlugin}
onClose={() => setEditHttpPlugin(undefined)}
/>
)}
<MoveConfirmModal />
</>
);
};
export default ListItem;

View File

@@ -0,0 +1,253 @@
import { useSelectFile } from '@/web/common/file/hooks/useSelectFile';
import { getMCPTools, postCreateMCPTools } from '@/web/core/app/api/plugin';
import {
Box,
Button,
Center,
Flex,
Input,
ModalBody,
ModalFooter,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr
} 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 { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { AppListContext } from './context';
import { useContextSelector } from 'use-context-selector';
import { ToolType } from '@fastgpt/global/core/app/type';
import { getMCPToolsBody } from '@/pages/api/core/app/mcpTools/getMCPTools';
export type MCPToolSetData = {
url: string;
toolList: ToolType[];
};
export type EditMCPToolsProps = {
avatar: string;
name: string;
mcpData: MCPToolSetData;
};
const MCPToolsEditModal = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const { toast } = useToast();
const { parentId, loadMyApps } = useContextSelector(AppListContext, (v) => v);
const { register, setValue, handleSubmit, watch } = useForm<EditMCPToolsProps>({
defaultValues: {
avatar: 'core/app/type/mcpToolsFill',
name: '',
mcpData: {
url: '',
toolList: []
}
}
});
const avatar = watch('avatar');
const mcpData = watch('mcpData');
const { runAsync: onCreate, loading: isCreating } = useRequest2(
async (data: EditMCPToolsProps) => {
return postCreateMCPTools({
name: data.name,
avatar: data.avatar,
toolList: data.mcpData.toolList,
url: data.mcpData.url,
parentId
});
},
{
onSuccess() {
onClose();
loadMyApps();
},
successToast: t('common:common.Create Success'),
errorToast: t('common:common.Create Failed')
}
);
const { runAsync: runGetMCPTools, loading: isGettingTools } = useRequest2(
(data: getMCPToolsBody) => getMCPTools(data),
{
onSuccess: (res) => {
setValue('mcpData.toolList', res);
},
errorToast: t('app:MCP_tools_parse_failed')
}
);
const {
File,
onOpen: onOpenSelectFile,
onSelectImage
} = useSelectFile({
fileType: 'image/*',
multiple: false
});
return (
<>
<MyModal
isOpen={true}
onClose={onClose}
iconSrc="core/app/type/mcpToolsFill"
title={t('app:type.MCP tools')}
w={['90vw', '530px']}
position={'relative'}
>
<ModalBody>
<Box color={'myGray.900'} fontSize={'14px'} fontWeight={'medium'}>
{t('common:common.Set Name')}
</Box>
<Flex mt={2} alignItems={'center'}>
<MyTooltip label={t('common:common.Set Avatar')}>
<Avatar
flexShrink={0}
src={avatar}
w={['28px', '32px']}
h={['28px', '32px']}
cursor={'pointer'}
borderRadius={'md'}
onClick={onOpenSelectFile}
/>
</MyTooltip>
<Input
flex={1}
ml={4}
bg={'myWhite.600'}
{...register('name', {
required: t('common:common.name_is_empty')
})}
/>
</Flex>
<Box color={'myGray.900'} fontSize={'14px'} fontWeight={'medium'} mt={6}>
{t('app:MCP_tools_url')}
</Box>
<Flex alignItems={'center'} gap={2} mt={2}>
<Input
h={8}
placeholder={t('app:MCP_tools_url_placeholder')}
{...register('mcpData.url', {
required: t('app:MCP_tools_url_is_empty')
})}
/>
<Button
size={'sm'}
variant={'whitePrimary'}
h={8}
isLoading={isGettingTools}
onClick={() => {
runGetMCPTools({ url: mcpData.url });
}}
>
{t('common:common.Parse')}
</Button>
</Flex>
<Box color={'myGray.900'} fontSize={'14px'} fontWeight={'medium'} mt={6}>
{t('app:MCP_tools_list')}
</Box>
<Box
mt={2}
borderRadius={'md'}
overflow={'hidden'}
borderWidth={'1px'}
position={'relative'}
>
<TableContainer maxH={360} minH={200} overflowY={'auto'}>
<Table bg={'white'}>
<Thead bg={'myGray.50'}>
<Th fontSize={'mini'} py={0} h={'34px'}>
{t('common:Name')}
</Th>
<Th fontSize={'mini'} py={0} h={'34px'}>
{t('common:plugin.Description')}
</Th>
</Thead>
<Tbody>
{mcpData.toolList.map((item) => (
<Tr key={item.name} height={'28px'}>
<Td
fontSize={'mini'}
color={'myGray.900'}
fontWeight={'medium'}
py={2}
maxW={1 / 2}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{item.name}
</Td>
<Td
fontSize={'mini'}
color={'myGray.900'}
fontWeight={'medium'}
py={2}
maxW={1 / 2}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{item.description}
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
{mcpData.toolList.length === 0 && (
<Center
position={'absolute'}
top={0}
left={0}
right={0}
bottom={0}
fontSize={'mini'}
color={'myGray.500'}
>
{t('app:no_mcp_tools_list')}
</Center>
)}
</Box>
</ModalBody>
<ModalFooter gap={2}>
<Button variant={'whitePrimary'} onClick={onClose}>
{t('common:common.Close')}
</Button>
<Button
isDisabled={mcpData.toolList.length === 0}
isLoading={isCreating}
onClick={handleSubmit(onCreate)}
>
{t('common:common.Confirm Create')}
</Button>
</ModalFooter>
</MyModal>
<File
onSelect={(e) =>
onSelectImage(e, {
maxH: 300,
maxW: 300,
callback: (e) => setValue('avatar', e)
})
}
/>
</>
);
};
export default MCPToolsEditModal;

View File

@@ -0,0 +1,66 @@
import React, { useRef } from 'react';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useI18n } from '@/web/context/I18n';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { Box, Flex } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
const AppTypeTag = ({ type }: { type: AppTypeEnum }) => {
const { t } = useTranslation();
const map = useRef({
[AppTypeEnum.simple]: {
label: t('app:type.Simple bot'),
icon: 'core/app/type/simple',
bg: '#DBF3FF',
color: '#0884DD'
},
[AppTypeEnum.workflow]: {
label: t('app:type.Workflow bot'),
icon: 'core/app/type/workflow',
bg: '#E4E1FC',
color: '#6F5DD7'
},
[AppTypeEnum.plugin]: {
label: t('app:type.Plugin'),
icon: 'core/app/type/plugin',
bg: '#D0F5EE',
color: '#007E7C'
},
[AppTypeEnum.httpPlugin]: {
label: t('app:type.Http plugin'),
icon: 'core/app/type/httpPlugin',
bg: '#FFE4EE',
color: '#E82F72'
},
[AppTypeEnum.toolSet]: {
label: t('app:type.MCP tools'),
icon: 'core/app/type/mcpTools',
bg: '',
color: ''
},
[AppTypeEnum.tool]: undefined,
[AppTypeEnum.folder]: undefined
});
const data = map.current[type];
return data ? (
<Flex
bg={'myGray.100'}
color={'myGray.600'}
py={0.5}
pl={2}
pr={3}
borderLeftRadius={'sm'}
whiteSpace={'nowrap'}
>
<MyIcon name={data.icon as any} w={'0.8rem'} color={'myGray.500'} />
<Box ml={1} fontSize={'mini'}>
{data.label}
</Box>
</Flex>
) : null;
};
export default AppTypeTag;

View File

@@ -0,0 +1,177 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { createContext } from 'use-context-selector';
import { useRouter } from 'next/router';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getAppDetailById, getMyApps, putAppById } from '@/web/core/app/api';
import { AppDetailType, AppListItemType } from '@fastgpt/global/core/app/type';
import { getAppFolderPath } from '@/web/core/app/api/app';
import {
GetResourceFolderListProps,
ParentIdType,
ParentTreePathItemType
} from '@fastgpt/global/common/parentFolder/type';
import { AppUpdateParams } from '@/global/core/app/api';
import dynamic from 'next/dynamic';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useTranslation } from 'next-i18next';
const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal'));
type AppListContextType = {
parentId?: string | null;
appType: AppTypeEnum | 'all';
myApps: AppListItemType[];
loadMyApps: () => Promise<AppListItemType[]>;
isFetchingApps: boolean;
folderDetail: AppDetailType | undefined | null;
paths: ParentTreePathItemType[];
onUpdateApp: (id: string, data: AppUpdateParams) => Promise<any>;
setMoveAppId: React.Dispatch<React.SetStateAction<string | undefined>>;
refetchFolderDetail: () => Promise<AppDetailType | null>;
searchKey: string;
setSearchKey: React.Dispatch<React.SetStateAction<string>>;
};
export const AppListContext = createContext<AppListContextType>({
parentId: undefined,
myApps: [],
loadMyApps: async function (): Promise<AppListItemType[]> {
throw new Error('Function not implemented.');
},
isFetchingApps: false,
folderDetail: undefined,
paths: [],
onUpdateApp: function (id: string, data: AppUpdateParams): Promise<any> {
throw new Error('Function not implemented.');
},
setMoveAppId: function (value: React.SetStateAction<string | undefined>): void {
throw new Error('Function not implemented.');
},
appType: 'all',
refetchFolderDetail: async function (): Promise<AppDetailType | null> {
throw new Error('Function not implemented.');
},
searchKey: '',
setSearchKey: function (value: React.SetStateAction<string>): void {
throw new Error('Function not implemented.');
}
});
const AppListContextProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation();
const router = useRouter();
const { parentId = null, type = 'all' } = router.query as {
parentId?: string | null;
type: AppTypeEnum;
};
const [searchKey, setSearchKey] = useState('');
const {
data = [],
runAsync: loadMyApps,
loading: isFetchingApps
} = useRequest2(
() => {
const formatType = (() => {
if (!type || type === 'all') return undefined;
if (type === AppTypeEnum.plugin)
return [AppTypeEnum.folder, AppTypeEnum.plugin, AppTypeEnum.httpPlugin];
return [AppTypeEnum.folder, type];
})();
return getMyApps({ parentId, type: formatType, searchKey });
},
{
manual: false,
refreshDeps: [searchKey, parentId, type],
throttleWait: 500,
refreshOnWindowFocus: true
}
);
const { data: paths = [], runAsync: refetchPaths } = useRequest2(
() => getAppFolderPath({ sourceId: parentId, type: 'current' }),
{
manual: false,
refreshDeps: [parentId]
}
);
const { data: folderDetail, runAsync: refetchFolderDetail } = useRequest2(
() => {
if (parentId) return getAppDetailById(parentId);
return Promise.resolve(null);
},
{
manual: false,
refreshDeps: [parentId]
}
);
const { runAsync: onUpdateApp } = useRequest2((id: string, data: AppUpdateParams) =>
putAppById(id, data).then(async (res) => {
await Promise.all([refetchFolderDetail(), refetchPaths(), loadMyApps()]);
return res;
})
);
const [moveAppId, setMoveAppId] = useState<string>();
const onMoveApp = useCallback(
async (parentId: ParentIdType) => {
if (!moveAppId) return;
await onUpdateApp(moveAppId, { parentId });
},
[moveAppId, onUpdateApp]
);
const getAppFolderList = useCallback(({ parentId }: GetResourceFolderListProps) => {
return getMyApps({
parentId,
type: AppTypeEnum.folder
}).then((res) =>
res
.filter((item) => item.permission.hasWritePer)
.map((item) => ({
id: item._id,
name: item.name
}))
);
}, []);
const { setLastAppListRouteType } = useSystemStore();
useEffect(() => {
setLastAppListRouteType(type);
}, [setLastAppListRouteType, type]);
const contextValue: AppListContextType = {
parentId,
appType: type,
myApps: data,
loadMyApps,
refetchFolderDetail,
isFetchingApps,
folderDetail,
paths,
onUpdateApp,
setMoveAppId,
searchKey,
setSearchKey
};
return (
<AppListContext.Provider value={contextValue}>
{children}
{!!moveAppId && (
<MoveModal
moveResourceId={moveAppId}
server={getAppFolderList}
title={t('app:move_app')}
onClose={() => setMoveAppId(undefined)}
onConfirm={onMoveApp}
moveHint={t('app:move.hint')}
/>
)}
</AppListContext.Provider>
);
};
export default AppListContextProvider;

View File

@@ -0,0 +1,414 @@
import React, { useState } from 'react';
import {
Box,
Button,
Checkbox,
Flex,
Grid,
HStack,
Input,
ModalBody,
ModalFooter,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
useDisclosure
} from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { McpAppType } from '@fastgpt/global/support/mcp/type';
import { useTranslation } from 'next-i18next';
import { useFieldArray, useForm } from 'react-hook-form';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import Path from '@/components/common/folder/Path';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getAppBasicInfoByIds, getMyApps } from '@/web/core/app/api';
import { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
import { getAppFolderPath } from '@/web/core/app/api/app';
import { AppFolderTypeList } from '@fastgpt/global/core/app/constants';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { postCreateMcpServer, putUpdateMcpServer } from '../../../web/support/mcp/api';
export type EditMcForm = {
id?: string;
name: string;
apps: McpAppType[];
};
export const defaultForm: EditMcForm = {
name: '',
apps: []
};
const SelectAppModal = ({
selectedApps,
onClose,
onConfirm
}: {
selectedApps: McpAppType[];
onClose: () => void;
onConfirm: (e: McpAppType[]) => void;
}) => {
const { t } = useTranslation();
const [selectedList, setSelectedList] = useState<
{
appId: string;
toolName: string;
avatar: string;
description: string;
}[]
>([]);
// Load selected app
useRequest2(() => getAppBasicInfoByIds(selectedApps.map((item) => item.appId)), {
manual: false,
onSuccess: (data) => {
setSelectedList(
data.map((item) => ({
appId: item.id,
toolName: item.name,
avatar: item.avatar,
description: selectedApps.find((app) => app.appId === item.id)?.description || ''
}))
);
}
});
// Load all apps
const [searchKey, setSearchKey] = useState('');
const [parentId, setParentId] = useState<ParentIdType>('');
const { data: apps = [], loading: loadingApps } = useRequest2(
() =>
getMyApps({
searchKey,
parentId
}),
{
manual: false,
refreshDeps: [searchKey, parentId],
throttleWait: 200
}
);
const { data: paths = [] } = useRequest2(
() => getAppFolderPath({ sourceId: parentId, type: 'current' }),
{
manual: false,
refreshDeps: [parentId]
}
);
const isLoading = loadingApps;
return (
<MyModal
isOpen
onClose={onClose}
iconSrc={'modal/AddClb'}
title={t('dashboard_mcp:select_app')}
minW="800px"
maxW={'60vw'}
h={'100%'}
maxH={'90vh'}
isCentered
isLoading={isLoading}
>
<ModalBody flex={'1'}>
<Grid
border="1px solid"
borderColor="myGray.200"
borderRadius="0.5rem"
gridTemplateColumns="1fr 1fr"
h={'100%'}
>
<Flex
h={'100%'}
flexDirection="column"
borderRight="1px solid"
borderColor="myGray.200"
p="4"
>
<SearchInput
placeholder={t('dashboard_mcp:search_app')}
bgColor="myGray.50"
onChange={(e) => setSearchKey(e.target.value)}
/>
{paths.length > 0 && !searchKey && (
<Box mt={3}>
<Path paths={paths} hoverStyle={{ bg: 'myGray.200' }} onClick={setParentId} />
</Box>
)}
<Box mt="3" overflow={'auto'} flex={'1 0 0'} h={0}>
{apps.map((item) => {
const selected = selectedList.some((app) => app.appId === item._id);
const isFolder = AppFolderTypeList.includes(item.type);
return (
<HStack
key={item._id}
py={2}
px={3}
borderRadius={'md'}
cursor={'pointer'}
_hover={{
bg: 'myGray.100'
}}
onClick={() => {
if (isFolder) {
setParentId(item._id);
} else if (selected) {
setSelectedList((state) => state.filter((app) => app.appId !== item._id));
} else {
setSelectedList((state) => [
...state,
{
appId: item._id,
toolName: item.name,
avatar: item.avatar,
description: item.intro
}
]);
}
}}
>
<Flex alignItems={'center'} w={'1.25rem'}>
{!isFolder && <Checkbox isChecked={selected} />}
</Flex>
<Avatar src={item.avatar} w="1.5rem" borderRadius={'sm'} />
<Box>{item.name}</Box>
</HStack>
);
})}
</Box>
</Flex>
<Flex h={'100%'} p="4" flexDirection="column">
<Box>
{`${t('dashboard_mcp:has_chosen')}: `}
{selectedList.length}
</Box>
<Flex flexDirection="column" mt="2" gap={1} overflow={'auto'} flex={'1 0 0'} h={0}>
{selectedList.map((item) => {
return (
<HStack
key={item.appId}
py={2}
px={3}
borderRadius={'md'}
cursor={'pointer'}
_hover={{
bg: 'myGray.100'
}}
>
<Avatar src={item.avatar} w="1.5rem" borderRadius={'sm'} />
<Box ml="2" flex={'1 0 0'}>
{item.toolName}
</Box>
<MyIcon
name="common/closeLight"
w="1rem"
cursor={'pointer'}
_hover={{
color: 'red.600'
}}
onClick={() => {
setSelectedList((state) => state.filter((app) => app.appId !== item.appId));
}}
/>
</HStack>
);
})}
</Flex>
</Flex>
</Grid>
</ModalBody>
<ModalFooter>
<Button ml="4" h={'32px'} onClick={() => onConfirm(selectedList)}>
{t('common:common.Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};
const EditMcpModal = ({
editMcp,
onClose,
onSuccess
}: {
editMcp: EditMcForm;
onClose: () => void;
onSuccess: () => void;
}) => {
const { t } = useTranslation();
const isEdit = !!editMcp.id;
console.log(editMcp);
const {
isOpen: isOpenSelectApp,
onOpen: onOpenSelectApp,
onClose: onCloseSelectApp
} = useDisclosure();
const { register, handleSubmit, control } = useForm({
defaultValues: editMcp
});
const {
fields: apps,
replace: replaceSelectedApps,
remove
} = useFieldArray({
control,
name: 'apps'
});
const { runAsync: createMcp, loading: loadingCreate } = useRequest2(
(data: EditMcForm) =>
postCreateMcpServer({
name: data.name,
apps: data.apps.map((item) => ({
appId: item.appId,
toolName: item.toolName,
description: item.description
}))
}),
{
manual: true,
successToast: t('common:common.Create Success'),
onSuccess
}
);
const { runAsync: updateMcp, loading: loadingUpdate } = useRequest2(
(data: EditMcForm) =>
putUpdateMcpServer({
id: data.id!,
name: data.name,
apps: data.apps.map((item) => ({
appId: item.appId,
toolName: item.toolName,
description: item.description
}))
}),
{
manual: true,
successToast: t('common:common.Update Success'),
onSuccess
}
);
const isConfirming = loadingCreate || loadingUpdate;
return (
<>
<MyModal
iconSrc="key"
title={isEdit ? '编辑MCP' : '创建MCP'}
w={'100%'}
maxW={['90vw', '600px']}
isOpen
onClose={onClose}
>
<ModalBody>
<Box>
<FormLabel required mb={0.5}>
{t('common:common.Input name')}
</FormLabel>
<Input {...register('name', { required: true })} bg={'myGray.50'} />
</Box>
<Box mt={6}>
<Flex justifyContent={'space-between'} alignItems={'center'}>
<FormLabel>{t('dashboard_mcp:apps')}</FormLabel>
<Button variant={'whiteBase'} size={'sm'} onClick={onOpenSelectApp}>
{t('dashboard_mcp:manage_app')}
</Button>
</Flex>
<TableContainer mt={2} position={'relative'}>
<Table>
<Thead>
<Tr>
<Th>{t('dashboard_mcp:app_name')}</Th>
<Th>{t('dashboard_mcp:app_description')}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody fontSize={'sm'}>
{apps.map((app, index) => {
return (
<Tr key={app.id} fontWeight={500} fontSize={'mini'} color={'myGray.900'}>
<Td>{app.toolName}</Td>
<Td>
<Input
{...register(`apps.${index}.description`, { required: true })}
bg={'myGray.50'}
w={'100%'}
/>
</Td>
<Td>
<Flex justifyContent={'flex-end'}>
<MyIconButton
icon="delete"
hoverColor={'red.600'}
onClick={() => remove(index)}
color={'myGray.600'}
/>
</Flex>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
{apps.length === 0 && <EmptyTip />}
</TableContainer>
</Box>
</ModalBody>
<ModalFooter>
<Button variant={'whiteBase'} mr={4} onClick={onClose}>
{t('common:common.Cancel')}
</Button>
<Button
isLoading={isConfirming}
variant={'primary'}
isDisabled={apps.length === 0}
onClick={handleSubmit((data) => {
if (isEdit) {
return updateMcp(data);
}
return createMcp(data);
})}
>
{t('common:common.Confirm')}
</Button>
</ModalFooter>
</MyModal>
{isOpenSelectApp && (
<SelectAppModal
selectedApps={apps}
onClose={onCloseSelectApp}
onConfirm={(e) => {
replaceSelectedApps(
e.map((item) => ({
appId: item.appId,
toolName: item.toolName,
description: item.description
}))
);
onCloseSelectApp();
}}
/>
)}
</>
);
};
export default EditMcpModal;

View File

@@ -0,0 +1,62 @@
import { McpKeyType } from '@fastgpt/global/support/mcp/type';
import MyModal from '@fastgpt/web/components/common/MyModal';
import React from 'react';
import { useTranslation } from 'next-i18next';
import { Box, Flex, HStack, ModalBody } from '@chakra-ui/react';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import CopyBox from '@fastgpt/web/components/common/String/CopyBox';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
const UsageWay = ({ mcp, onClose }: { mcp: McpKeyType; onClose: () => void }) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const sseUrl = `${feConfigs?.mcpServerProxyEndpoint}/${mcp.key}/sse`;
const jsonConfig = `{
"mcpServers": {
"${feConfigs?.systemTitle}-mcp-${mcp._id}": {
"url": "${sseUrl}"
}
}
}`;
return (
<MyModal isOpen title={t('dashboard_mcp:usage_way')} onClose={onClose}>
<ModalBody>
<Box>
<FormLabel>{t('dashboard_mcp:mcp_endpoints')}</FormLabel>
<HStack mt={0.5} bg={'myGray.50'} px={2} py={1} borderRadius={'md'} fontSize={'sm'}>
<Box userSelect={'all'} flex={'1 0 0'} whiteSpace={'pre-wrap'} wordBreak={'break-all'}>
{sseUrl}
</Box>
<CopyBox value={sseUrl}>
<MyIconButton icon="copy" />
</CopyBox>
</HStack>
</Box>
<Box mt={4}>
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'}>
<Flex
p={3}
bg={'myWhite.500'}
border={'base'}
borderTopLeftRadius={'md'}
borderTopRightRadius={'md'}
>
<Box flex={1}>{t('dashboard_mcp:mcp_json_config')}</Box>
<CopyBox value={jsonConfig}>
<MyIconButton icon="copy" />
</CopyBox>
</Flex>
<Box whiteSpace={'pre-wrap'} wordBreak={'break-all'} p={3} overflowX={'auto'}>
{jsonConfig}
</Box>
</Box>
</Box>
</ModalBody>
</MyModal>
);
};
export default React.memo(UsageWay);