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

@@ -73,7 +73,6 @@ const DetailLogsModal = ({ appId, chatId, onClose }: Props) => {
return (
<>
<MyBox
isLoading={isFetching}
display={'flex'}
flexDirection={'column'}
zIndex={3}

View File

@@ -0,0 +1,86 @@
import { Box, Button, Flex, HStack, IconButton } from '@chakra-ui/react';
import React, { useState } from 'react';
import { AppContext } from '../context';
import { useContextSelector } from 'use-context-selector';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useTranslation } from 'react-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyMenu from '@fastgpt/web/components/common/MyMenu';
import { AppSchema } from '@fastgpt/global/core/app/type';
import TagsEditModal from '../TagsEditModal';
const AppCard = () => {
const { t } = useTranslation();
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const onOpenInfoEdit = useContextSelector(AppContext, (v) => v.onOpenInfoEdit);
const onDelApp = useContextSelector(AppContext, (v) => v.onDelApp);
const [TeamTagsSet, setTeamTagsSet] = useState<AppSchema>();
return (
<>
<Box px={[4, 6]} py={4} position={'relative'}>
<Flex alignItems={'center'}>
<Avatar src={appDetail.avatar} borderRadius={'md'} w={'28px'} />
<Box ml={3} fontWeight={'bold'} fontSize={'md'} flex={'1 0 0'} color={'myGray.900'}>
{appDetail.name}
</Box>
</Flex>
<Box
flex={1}
mt={3}
mb={4}
className={'textEllipsis3'}
wordBreak={'break-all'}
color={'myGray.600'}
fontSize={'xs'}
minH={'46px'}
>
{appDetail.intro || t('common:core.app.tip.Add a intro to app')}
</Box>
<HStack alignItems={'center'}>
{appDetail.permission.hasManagePer && (
<Button
size={['sm', 'md']}
variant={'whitePrimary'}
leftIcon={<MyIcon name={'common/settingLight'} w={'16px'} />}
onClick={onOpenInfoEdit}
>
{t('common:common.Setting')}
</Button>
)}
{appDetail.permission.isOwner && (
<MyMenu
size={'xs'}
Button={
<IconButton
variant={'whitePrimary'}
size={['smSquare', 'mdSquare']}
icon={<MyIcon name={'more'} w={'1rem'} />}
aria-label={''}
/>
}
menuList={[
{
children: [
{
icon: 'delete',
type: 'danger',
label: t('common:common.Delete'),
onClick: onDelApp
}
]
}
]}
/>
)}
<Box flex={1} />
</HStack>
</Box>
{TeamTagsSet && <TagsEditModal onClose={() => setTeamTagsSet(undefined)} />}
</>
);
};
export default React.memo(AppCard);

View File

@@ -0,0 +1,241 @@
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import React, { useEffect, useMemo, useState } from 'react';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import ChatItemContextProvider from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { Box, Button, Flex, Switch, Textarea } from '@chakra-ui/react';
import { cardStyles } from '../constants';
import { useTranslation } from 'react-i18next';
import { ToolType } from '@fastgpt/global/core/app/type';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip';
import { Controller, useForm } from 'react-hook-form';
import MyNumberInput from '@fastgpt/web/components/common/Input/NumberInput';
import dynamic from 'next/dynamic';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import Markdown from '@/components/Markdown';
import { postRunMCPTools } from '@/web/core/app/api/plugin';
const JsonEditor = dynamic(() => import('@fastgpt/web/components/common/Textarea/JsonEditor'));
const ChatTest = ({ currentTool, url }: { currentTool: ToolType | null; url: string }) => {
const { t } = useTranslation();
const [output, setOutput] = useState<string>('');
const {
control,
handleSubmit,
reset,
formState: { errors }
} = useForm();
useEffect(() => {
reset({});
setOutput('');
}, [currentTool, reset]);
const { runAsync: runTool, loading: isRunning } = useRequest2(
async (data: Record<string, any>) => {
if (!currentTool) return;
return await postRunMCPTools({
params: data,
url,
toolName: currentTool.name
});
},
{
onSuccess: (res) => {
try {
const resStr = JSON.stringify(res, null, 2);
setOutput(resStr);
} catch (error) {
console.error(error);
}
}
}
);
return (
<Flex h={'full'} gap={2}>
<Box
flex={'1 0 0'}
w={0}
display={'flex'}
position={'relative'}
flexDirection={'column'}
h={'full'}
py={4}
{...cardStyles}
boxShadow={'3'}
>
<Flex px={[2, 5]} pb={4}>
<Box fontSize={['md', 'lg']} fontWeight={'bold'} color={'myGray.900'} mr={3}>
{t('app:chat_debug')}
</Box>
<Box flex={1} />
</Flex>
<Box flex={1} px={[2, 5]} overflow={'auto'}>
{Object.keys(currentTool?.inputSchema.properties || {}).length > 0 && (
<>
<Box color={'myGray.900'} fontSize={'16px'} fontWeight={'medium'} mb={3}>
{t('common:common.Input')}
</Box>
<Box border={'1px solid'} borderColor={'myGray.200'} borderRadius={'8px'} p={3}>
{Object.entries(currentTool?.inputSchema.properties || {}).map(
([paramName, paramInfo]) => (
<Controller
key={paramName}
control={control}
name={paramName}
rules={{
validate: (value) => {
if (!currentTool?.inputSchema.required?.includes(paramName)) return true;
return !!value;
}
}}
render={({ field: { onChange, value } }) => {
return (
<RenderToolInput
paramName={paramName}
paramInfo={paramInfo}
toolData={currentTool}
isInvalid={errors && Object.keys(errors).includes(paramName)}
value={value}
onChange={onChange}
/>
);
}}
/>
)
)}
</Box>
</>
)}
<Button mt={3} isLoading={isRunning} onClick={handleSubmit(runTool)}>
{t('common:common.Run')}
</Button>
{output && (
<>
<Box color={'myGray.900'} fontSize={'16px'} fontWeight={'medium'} mb={3} mt={8}>
{t('common:common.Output')}
</Box>
<Box>
<Markdown source={`~~~json\n${output}`} />
</Box>
</>
)}
</Box>
</Box>
</Flex>
);
};
const Render = ({ currentTool, url }: { currentTool: ToolType | null; url: string }) => {
const { chatId } = useChatStore();
const { appDetail } = useContextSelector(AppContext, (v) => v);
const chatRecordProviderParams = useMemo(
() => ({
chatId: chatId,
appId: appDetail._id
}),
[appDetail._id, chatId]
);
return (
<ChatItemContextProvider
showRouteToAppDetail={true}
showRouteToDatasetDetail={true}
isShowReadRawSource={true}
// isShowFullText={true}
showNodeStatus
>
<ChatRecordContextProvider params={chatRecordProviderParams}>
<ChatTest currentTool={currentTool} url={url} />
</ChatRecordContextProvider>
</ChatItemContextProvider>
);
};
export default React.memo(Render);
const RenderToolInput = ({
paramName,
paramInfo,
toolData,
value,
onChange,
isInvalid
}: {
paramName: string;
paramInfo: {
type: string;
description?: string;
};
toolData: ToolType | null;
value: any;
onChange: (value: any) => void;
isInvalid: boolean;
}) => {
const render = (() => {
if (paramInfo.type === 'string') {
return (
<Textarea
value={value}
onChange={onChange}
placeholder={paramInfo.description}
bg={'myGray.50'}
isInvalid={isInvalid}
/>
);
}
if (paramInfo.type === 'number') {
return (
<MyNumberInput
step={1}
bg={'myGray.50'}
isInvalid={isInvalid}
value={value}
onChange={onChange}
/>
);
}
if (paramInfo.type === 'boolean') {
return <Switch isChecked={value} onChange={onChange} isInvalid={isInvalid} />;
}
return (
<JsonEditor
bg={'myGray.50'}
placeholder={paramInfo.description}
resize
value={value}
onChange={onChange}
isInvalid={isInvalid}
/>
);
})();
return (
<Box _notLast={{ mb: 4 }}>
<Flex alignItems="center" mb={1}>
{toolData?.inputSchema.required?.includes(paramName) && (
<Box mr={1} color="red.500">
*
</Box>
)}
<FormLabel fontSize="14px" fontWeight={'normal'} color="myGray.900">
{paramName}
<QuestionTip label={paramInfo.description} ml={1} />
</FormLabel>
</Flex>
{render}
</Box>
);
};

View File

@@ -0,0 +1,71 @@
import { Box, Flex } from '@chakra-ui/react';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import React from 'react';
import styles from '../SimpleApp/styles.module.scss';
import { cardStyles } from '../constants';
import AppCard from './AppCard';
import ChatTest from './ChatTest';
import MyBox from '@fastgpt/web/components/common/MyBox';
import EditForm from './EditForm';
import { ToolType } from '@fastgpt/global/core/app/type';
const Edit = ({
url,
setUrl,
toolList,
setToolList,
currentTool,
setCurrentTool
}: {
url: string;
setUrl: (url: string) => void;
toolList: ToolType[];
setToolList: (toolList: ToolType[]) => void;
currentTool: ToolType | null;
setCurrentTool: (tool: ToolType) => void;
}) => {
const { isPc } = useSystem();
return (
<MyBox
display={['block', 'flex']}
flex={'1 0 0'}
h={0}
mt={[4, 0]}
gap={1}
borderRadius={'lg'}
overflowY={['auto', 'unset']}
>
<Flex
flexDirection={'column'}
className={styles.EditAppBox}
pr={[0, 1]}
minW={['auto', '580px']}
mb={3}
flex={'1 0 0'}
>
<Box {...cardStyles} boxShadow={'2'}>
<AppCard />
</Box>
<Box mt={4} {...cardStyles} flex={'1 0 0'} overflow={'auto'} boxShadow={'2'}>
<EditForm
toolList={toolList}
setToolList={setToolList}
currentTool={currentTool}
setCurrentTool={setCurrentTool}
url={url}
setUrl={setUrl}
/>
</Box>
</Flex>
{isPc && (
<Box flex={'2 0 0'} w={0} mb={3}>
<ChatTest currentTool={currentTool} url={url} />
</Box>
)}
</MyBox>
);
};
export default React.memo(Edit);

View File

@@ -0,0 +1,264 @@
import { Box, Button, Flex, Input, ModalBody, ModalFooter } from '@chakra-ui/react';
import React, { useState } from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
import { useTranslation } from 'react-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { getMCPTools } from '@/web/core/app/api/plugin';
import { AppContext } from '../context';
import { useContextSelector } from 'use-context-selector';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import { ToolType } from '@fastgpt/global/core/app/type';
import MyModal from '@fastgpt/web/components/common/MyModal';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyBox from '@fastgpt/web/components/common/MyBox';
import { getMCPToolsBody } from '@/pages/api/core/app/mcpTools/getMCPTools';
const EditForm = ({
url,
setUrl,
toolList,
setToolList,
currentTool,
setCurrentTool
}: {
url: string;
setUrl: (url: string) => void;
toolList: ToolType[];
setToolList: (toolList: ToolType[]) => void;
currentTool: ToolType | null;
setCurrentTool: (tool: ToolType) => void;
}) => {
const { t } = useTranslation();
const [toolDetail, setToolDetail] = useState<ToolType | null>(null);
const { runAsync: runGetMCPTools, loading: isGettingTools } = useRequest2(
async (data: getMCPToolsBody) => await getMCPTools(data),
{
onSuccess: (res) => {
setToolList(res);
setCurrentTool(res[0]);
},
errorToast: t('app:MCP_tools_parse_failed')
}
);
return (
<>
<Box p={6}>
<Flex alignItems={'center'}>
<MyIcon name={'common/linkBlue'} w={'20px'} />
<FormLabel ml={2} flex={1}>
{t('app:MCP_tools_url')}
</FormLabel>
</Flex>
<Flex alignItems={'center'} gap={2} mt={3}>
<Input
h={8}
placeholder={t('app:MCP_tools_url_placeholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button
size={'sm'}
variant={'whitePrimary'}
h={8}
isLoading={isGettingTools}
onClick={() => {
runGetMCPTools({ url });
}}
>
{t('common:common.Parse')}
</Button>
</Flex>
<Flex alignItems={'center'} mt={6}>
<MyIcon name={'common/list'} w={'20px'} color={'primary.600'} />
<FormLabel ml={2} flex={1}>
{t('app:MCP_tools_list_with_number', {
total: toolList.length || 0
})}
</FormLabel>
</Flex>
<Box mt={3}>
{toolList.map((tool, index) => {
return (
<MyBox
key={tool.name}
role="group"
position="relative"
border={'1px solid'}
{...(currentTool?.name === tool.name
? {
borderRadius: '8px',
borderColor: 'primary.600',
borderBottomColor: 'primary.600',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}
: {
borderRadius: 'none',
borderColor: 'transparent',
borderBottomColor: 'myGray.150',
boxShadow: 'none'
})}
_hover={{
borderRadius: '8px',
boxShadow:
'0px 4px 4px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)'
}}
>
<Flex alignItems={'center'} py={2} px={3}>
<Box w={'20px'} fontSize={'14px'} color={'myGray.500'} fontWeight={'medium'}>
{index + 1 < 10 ? `0${index + 1}` : index + 1}
</Box>
<Box maxW={'full'} pl={2} position="relative" width="calc(100% - 30px)">
<Box
fontSize={'14px'}
color={'myGray.900'}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{tool.name}
</Box>
<Box
fontSize={'12px'}
color={'myGray.500'}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{tool.description || t('app:tools_no_description')}
</Box>
</Box>
<Box flex={1} />
</Flex>
<Flex
position="absolute"
right={3}
top="50%"
transform="translateY(-50%)"
gap={2}
display="none"
_groupHover={{ display: 'flex' }}
background="linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 15%, rgba(255,255,255,1) 100%)"
paddingLeft="20px"
>
<MyIconButton
size={'16px'}
icon={'common/detail'}
p={2}
border={'1px solid'}
borderColor={'myGray.250'}
hoverBg={'rgba(51, 112, 255, 0.10)'}
hoverBorderColor={'primary.300'}
tip={t('app:MCP_tools_detail')}
onClick={() => {
setToolDetail(tool);
}}
/>
<MyIconButton
size={'16px'}
icon={'core/workflow/debug'}
p={2}
border={'1px solid'}
borderColor={'myGray.250'}
hoverBg={'rgba(51, 112, 255, 0.10)'}
hoverBorderColor={'primary.300'}
tip={t('app:MCP_tools_debug')}
onClick={() => {
setCurrentTool(tool);
}}
/>
</Flex>
</MyBox>
);
})}
</Box>
</Box>
{toolDetail && <ToolDetailModal tool={toolDetail} onClose={() => setToolDetail(null)} />}
</>
);
};
export default React.memo(EditForm);
const ToolDetailModal = ({ tool, onClose }: { tool: ToolType; onClose: () => void }) => {
const { t } = useTranslation();
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
return (
<MyModal
isOpen={true}
iconSrc="common/detail"
iconColor={'primary.600'}
title={t('app:tool_detail')}
onClose={onClose}
w={'530px'}
>
<ModalBody>
<Flex pb={6} borderBottom={'1px solid'} borderColor={'myGray.200'}>
<Avatar src={appDetail.avatar} borderRadius={'md'} w={'40px'} />
<Box ml={'14px'}>
<Box fontSize={'16px'} color={'myGray.900'}>
{tool.name}
</Box>
<Box fontSize={'12px'} color={'myGray.500'}>
{tool.description}
</Box>
</Box>
</Flex>
<Box mt={6} color={'myGray.900'} fontWeight={'medium'}>
{t('common:common.Params')}
</Box>
<Box mt={3}>
{Object.entries(tool.inputSchema.properties || {}).map(
([paramName, paramInfo]: [string, any]) => (
<Box key={paramName} py={2} borderBottom={'1px solid'} borderColor={'myGray.150'}>
<Flex alignItems="center">
{tool.inputSchema.required?.includes(paramName) && (
<Box mr={1} color="red.500">
*
</Box>
)}
<Box fontSize="14px" color="myGray.900">
{paramName}
</Box>
<Box
ml={1}
fontSize={'12px'}
color={'myGray.600'}
px={1}
bg={'myGray.25'}
borderRadius={'sm'}
border={'1px solid'}
borderColor={'myGray.200'}
>
{paramInfo.type}
</Box>
</Flex>
<Box mt={1} fontSize="13px" color="myGray.600">
{paramInfo.description}
</Box>
</Box>
)
)}
</Box>
</ModalBody>
<ModalFooter>
<Button size={'md'} onClick={onClose}>
{t('common:common.Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
};

View File

@@ -0,0 +1,81 @@
import { Box, Button, Flex } from '@chakra-ui/react';
import FolderPath from '@/components/common/folder/Path';
import { useTranslation } from 'react-i18next';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { getAppFolderPath } from '@/web/core/app/api/app';
import { useCallback } from 'react';
import { useRouter } from 'next/router';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { ToolType } from '@fastgpt/global/core/app/type';
import { postUpdateMCPTools } from '@/web/core/app/api/plugin';
const Header = ({ url, toolList }: { url: string; toolList: ToolType[] }) => {
const { t } = useTranslation();
const appId = useContextSelector(AppContext, (v) => v.appId);
const router = useRouter();
const { lastAppListRouteType } = useSystemStore();
const { data: paths = [] } = useRequest2(
() => getAppFolderPath({ sourceId: appId, type: 'parent' }),
{
manual: false,
refreshDeps: [appId]
}
);
const onClickRoute = useCallback(
(parentId: string) => {
router.push({
pathname: '/dashboard/apps',
query: {
parentId,
type: lastAppListRouteType
}
});
},
[router, lastAppListRouteType]
);
const { runAsync: saveMCPTools, loading: isSavingMCPTools } = useRequest2(
async () => {
return await postUpdateMCPTools({ appId, url, toolList });
},
{
successToast: t('common:common.Update Success')
}
);
return (
<Box h={14}>
{/* {!isPc && (
<Flex justifyContent={'center'}>
<RouteTab />
</Flex>
)} */}
<Flex w={'full'} alignItems={'center'} position={'relative'} h={'full'}>
<Box flex={'1'}>
<FolderPath
rootName={t('app:all_apps')}
paths={paths}
hoverStyle={{ color: 'primary.600' }}
onClick={onClickRoute}
fontSize={'14px'}
/>
</Box>
{/* {isPc && (
<Box position={'absolute'} left={'50%'} transform={'translateX(-50%)'}>
<RouteTab />
</Box>
)} */}
<Button size={'sm'} isLoading={isSavingMCPTools} onClick={() => saveMCPTools()}>
{t('common:common.Save')}
</Button>
</Flex>
</Box>
);
};
export default Header;

View File

@@ -0,0 +1,39 @@
import { Box, Flex } from '@chakra-ui/react';
import React, { useMemo, useState } from 'react';
import Header from './Header';
import Edit from './Edit';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { ToolType } from '@fastgpt/global/core/app/type';
import { MCPToolSetData } from '@/pageComponents/dashboard/apps/MCPToolsEditModal';
const MCPTools = () => {
const appDetail = useContextSelector(AppContext, (v) => v.appDetail);
const toolSetData = useMemo(() => {
const toolSetNode = appDetail.modules.find(
(item) => item.flowNodeType === FlowNodeTypeEnum.toolSet
);
return toolSetNode?.inputs[0].value as MCPToolSetData;
}, [appDetail.modules]);
const [url, setUrl] = useState(toolSetData?.url || '');
const [toolList, setToolList] = useState<ToolType[]>(toolSetData?.toolList || []);
const [currentTool, setCurrentTool] = useState<ToolType | null>(toolSetData?.toolList[0] || null);
return (
<Flex h={'100%'} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}>
<Header url={url} toolList={toolList} />
<Edit
url={url}
setUrl={setUrl}
toolList={toolList}
setToolList={setToolList}
currentTool={currentTool}
setCurrentTool={setCurrentTool}
/>
</Flex>
);
};
export default React.memo(MCPTools);

View File

@@ -103,7 +103,7 @@ const Header = () => {
const onBack = useCallback(async () => {
leaveSaveSign.current = false;
router.push({
pathname: '/app/list',
pathname: '/dashboard/apps',
query: {
parentId: appDetail.parentId,
type: lastAppListRouteType

View File

@@ -1,4 +1,4 @@
import { Box, Button, Flex, IconButton } from '@chakra-ui/react';
import { Box, Flex, IconButton } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import React, { useEffect, useMemo } from 'react';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
@@ -10,7 +10,6 @@ import { form2AppWorkflow } from '@/web/core/app/utils';
import { useContextSelector } from 'use-context-selector';
import { AppContext } from '../context';
import { useChatTest } from '../useChatTest';
import { useDatasetStore } from '@/web/core/dataset/store/dataset';
import ChatItemContextProvider, { ChatItemContext } from '@/web/core/chat/context/chatItemContext';
import ChatRecordContextProvider from '@/web/core/chat/context/chatRecordContext';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
@@ -57,7 +56,6 @@ const ChatTest = ({ appForm, setRenderEdit }: Props) => {
<MyBox
flex={'1 0 0'}
w={0}
isLoading={loading}
display={'flex'}
position={'relative'}
flexDirection={'column'}

View File

@@ -71,7 +71,7 @@ const Header = ({
const onClickRoute = useCallback(
(parentId: string) => {
router.push({
pathname: '/app/list',
pathname: '/dashboard/apps',
query: {
parentId,
type: lastAppListRouteType

View File

@@ -9,7 +9,10 @@ import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { theme } from '@fastgpt/web/styles/theme';
import DeleteIcon, { hoverDeleteStyles } from '@fastgpt/web/components/common/Icon/delete';
import ToolSelectModal, { childAppSystemKey } from './ToolSelectModal';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import Avatar from '@fastgpt/web/components/common/Avatar';
import ConfigToolModal from './ConfigToolModal';
import { getWebLLMModel } from '@/web/common/system/utils';
@@ -90,7 +93,9 @@ const ToolSelect = ({
input.renderTypeList.includes(FlowNodeInputTypeEnum.selectLLMModel) ||
input.renderTypeList.includes(FlowNodeInputTypeEnum.fileSelect)
) ||
hasError
hasError ||
item.flowNodeType === FlowNodeTypeEnum.tool ||
item.flowNodeType === FlowNodeTypeEnum.toolSet
) {
return;
}

View File

@@ -471,6 +471,31 @@ const RenderList = React.memo(function RenderList({
>
{t('common:common.Remove')}
</Button>
) : template.flowNodeType === 'toolSet' ? (
<Flex gap={2}>
<Button
size={'sm'}
variant={'whiteBase'}
isLoading={isLoading}
leftIcon={<MyIcon name={'common/arrowRight'} w={'16px'} mr={-1.5} />}
onClick={() => setParentId(template.id)}
px={2}
fontSize={'mini'}
>
{t('common:common.Open')}
</Button>
<Button
size={'sm'}
variant={'primaryOutline'}
leftIcon={<MyIcon name={'common/addLight'} w={'16px'} mr={-1.5} />}
isLoading={isLoading}
onClick={() => onClickAdd(template)}
px={2}
fontSize={'mini'}
>
{t('common:common.Add')}
</Button>
</Flex>
) : template.isFolder ? (
<Button
size={'sm'}

View File

@@ -107,7 +107,7 @@ const Header = () => {
const onBack = useCallback(async () => {
leaveSaveSign.current = false;
router.push({
pathname: '/app/list',
pathname: '/dashboard/apps',
query: {
parentId: appDetail.parentId,
type: lastAppListRouteType

View File

@@ -65,7 +65,6 @@ const ChatTest = ({ isOpen, nodes = [], edges = [], onClose }: Props) => {
}}
/>
<MyBox
isLoading={isPlugin && loading}
zIndex={300}
display={'flex'}
flexDirection={'column'}

View File

@@ -364,7 +364,7 @@ const RenderHeader = React.memo(function RenderHeader({
color: 'primary.600'
}}
fontSize={'sm'}
onClick={() => router.push('/app/list')}
onClick={() => router.push('/dashboard/apps')}
gap={1}
>
<Box>{t('common:create')}</Box>
@@ -474,7 +474,8 @@ const RenderList = React.memo(function RenderList({
// get plugin preview module
if (
template.flowNodeType === FlowNodeTypeEnum.pluginModule ||
template.flowNodeType === FlowNodeTypeEnum.appModule
template.flowNodeType === FlowNodeTypeEnum.appModule ||
template.flowNodeType === FlowNodeTypeEnum.toolSet
) {
setLoading(true);
const res = await getPreviewPluginNode({ appId: template.id });
@@ -614,11 +615,9 @@ const RenderList = React.memo(function RenderList({
}
})}
>
<Flex>
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
{t(item.label as any)}
</Box>
</Flex>
<Box fontSize={'sm'} my={2} fontWeight={'500'} flex={1} color={'myGray.900'}>
{t(item.label as any)}
</Box>
<Grid gridTemplateColumns={gridStyle.gridTemplateColumns} rowGap={2}>
{item.list.map((template) => {
return (
@@ -655,9 +654,16 @@ const RenderList = React.memo(function RenderList({
py={gridStyle.py}
px={3}
cursor={'pointer'}
_hover={{ bg: 'myWhite.600' }}
_hover={{
bg: 'myWhite.600',
'& .arrowIcon': {
display: 'block'
}
}}
borderRadius={'sm'}
draggable={!template.isFolder}
draggable={
!template.isFolder || template.flowNodeType === FlowNodeTypeEnum.toolSet
}
onDragEnd={(e) => {
if (e.clientX < sliderWidth) return;
onAddNode({
@@ -666,7 +672,10 @@ const RenderList = React.memo(function RenderList({
});
}}
onClick={(e) => {
if (template.isFolder) {
if (
template.isFolder &&
template.flowNodeType !== FlowNodeTypeEnum.toolSet
) {
return setParentId(template.id);
}
if (isPc) {
@@ -703,6 +712,26 @@ const RenderList = React.memo(function RenderList({
{t(template.name as any)}
</Box>
{template.isFolder && (
<Box
color={'myGray.500'}
_hover={{
bg: 'var(--light-general-surface-opacity-005, rgba(17, 24, 36, 0.05))',
color: 'primary.600'
}}
p={1}
rounded={'sm'}
className="arrowIcon"
display="none"
onClick={(e) => {
e.stopPropagation();
return setParentId(template.id);
}}
>
<MyIcon name="common/arrowRight" w={'24px'} />
</Box>
)}
{gridStyle.authorInRight && template.authorAvatar && template.author && (
<HStack spacing={1} maxW={'120px'} flexShrink={0}>
<MyAvatar src={template.authorAvatar} w={'1rem'} borderRadius={'50%'} />

View File

@@ -47,6 +47,8 @@ const nodeTypes: Record<FlowNodeTypeEnum, any> = {
[FlowNodeTypeEnum.stopTool]: (data: NodeProps<FlowNodeItemType>) => (
<NodeSimple {...data} minW={'100px'} maxW={'300px'} />
),
[FlowNodeTypeEnum.tool]: dynamic(() => import('./nodes/NodeTool')),
[FlowNodeTypeEnum.toolSet]: dynamic(() => import('./nodes/NodeToolSet')),
[FlowNodeTypeEnum.toolParams]: dynamic(() => import('./nodes/NodeToolParams')),
[FlowNodeTypeEnum.lafModule]: dynamic(() => import('./nodes/NodeLaf')),
[FlowNodeTypeEnum.ifElseNode]: dynamic(() => import('./nodes/NodeIfElse')),

View File

@@ -0,0 +1,46 @@
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React from 'react';
import { NodeProps } from 'reactflow';
import NodeCard from './render/NodeCard';
import IOTitle from '../components/IOTitle';
import Container from '../components/Container';
import { useTranslation } from 'react-i18next';
import RenderOutput from './render/RenderOutput';
import RenderInput from './render/RenderInput';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
import RenderToolInput from './render/RenderToolInput';
const NodeTool = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { nodeId, inputs, outputs } = data;
const splitToolInputs = useContextSelector(WorkflowContext, (v) => v.splitToolInputs);
const { commonInputs, isTool } = splitToolInputs(inputs, nodeId);
return (
<NodeCard minW={'350px'} selected={selected} {...data}>
{isTool && (
<>
<Container>
<RenderToolInput nodeId={nodeId} inputs={inputs} />
</Container>
</>
)}
<>
<Container>
<IOTitle text={t('common:common.Input')} />
<RenderInput nodeId={nodeId} flowInputList={commonInputs} />
</Container>
</>
<>
<Container>
<IOTitle text={t('common:common.Output')} />
<RenderOutput flowOutputList={outputs} nodeId={nodeId} />
</Container>
</>
</NodeCard>
);
};
export default React.memo(NodeTool);

View File

@@ -0,0 +1,63 @@
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import React from 'react';
import { NodeProps } from 'reactflow';
import NodeCard from './render/NodeCard';
import IOTitle from '../components/IOTitle';
import Container from '../components/Container';
import { useTranslation } from 'react-i18next';
import { ToolType } from '@fastgpt/global/core/app/type';
import { Box, Flex } from '@chakra-ui/react';
const NodeToolSet = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { t } = useTranslation();
const { inputs } = data;
const toolList: ToolType[] = inputs.find((item) => item.key === 'toolSetData')?.value?.toolList;
return (
<NodeCard minW={'350px'} selected={selected} {...data}>
<Container>
<IOTitle text={t('app:MCP_tools_list')} />
<Box maxH={'500px'} overflowY={'auto'} className="nowheel">
{toolList?.map((tool, index) => (
<Flex
key={index}
borderBottom={'1px solid'}
borderColor={'myGray.200'}
alignItems={'center'}
py={2}
px={3}
>
<Box w={'20px'} fontSize={'14px'} color={'myGray.500'} fontWeight={'medium'}>
{index + 1 < 10 ? `0${index + 1}` : index + 1}
</Box>
<Box maxW={'full'} pl={2} position="relative" width="400px">
<Box
fontSize={'14px'}
color={'myGray.900'}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{tool.name}
</Box>
<Box
fontSize={'12px'}
color={'myGray.500'}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{tool.description || t('app:tools_no_description')}
</Box>
</Box>
<Box flex={1} />
</Flex>
))}
</Box>
</Container>
</NodeCard>
);
};
export default React.memo(NodeToolSet);

View File

@@ -105,7 +105,9 @@ const NodeCard = (props: Props) => {
if (
node?.flowNodeType === FlowNodeTypeEnum.pluginModule ||
node?.flowNodeType === FlowNodeTypeEnum.appModule
node?.flowNodeType === FlowNodeTypeEnum.appModule ||
node?.flowNodeType === FlowNodeTypeEnum.tool ||
node?.flowNodeType === FlowNodeTypeEnum.toolSet
) {
return { ...node, ...node.pluginData };
} else {

View File

@@ -15,7 +15,7 @@ import {
import { UserInputInteractive } from '@fastgpt/global/core/workflow/template/system/interactive/type';
import {
getLastInteractiveValue,
initWorkflowEdgeStatus
storeEdges2RuntimeEdges
} from '@fastgpt/global/core/workflow/runtime/utils';
import { ChatItemType, UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { ChatItemValueTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
@@ -137,7 +137,7 @@ const NodeDebugResponse = ({ nodeId, debugResult }: NodeDebugResponseProps) => {
onNextNodeDebug({
...workflowDebugData,
// Rewrite runtimeEdges
runtimeEdges: initWorkflowEdgeStatus(workflowDebugData.runtimeEdges, lastInteractive),
runtimeEdges: storeEdges2RuntimeEdges(workflowDebugData.runtimeEdges, lastInteractive),
query: updatedQuery,
history: mockHistory
});

View File

@@ -137,7 +137,7 @@ const AppContextProvider = ({ children }: { children: ReactNode }) => {
refreshDeps: [appId],
errorToast: t('common:core.app.error.Get app failed'),
onError(err: any) {
router.replace('/app/list');
router.replace('/dashboard/apps');
},
onSuccess(res) {
setAppDetail(res);
@@ -189,7 +189,7 @@ const AppContextProvider = ({ children }: { children: ReactNode }) => {
},
{
onSuccess() {
router.replace(`/app/list`);
router.replace(`/dashboard/apps`);
},
successToast: t('common:common.Delete Success'),
errorToast: t('common:common.Delete Failed')

View File

@@ -1,481 +0,0 @@
import {
Box,
Button,
Flex,
Grid,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay
} from '@chakra-ui/react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import { useCallback, useMemo, useState } from 'react';
import MyBox from '@fastgpt/web/components/common/MyBox';
import AppTypeTag from './TypeTag';
import { AppTemplateTypeEnum, AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import {
getTemplateMarketItemDetail,
getTemplateMarketItemList,
getTemplateTagList
} from '@/web/core/app/api/template';
import { postCreateApp } from '@/web/core/app/api';
import { useContextSelector } from 'use-context-selector';
import { AppListContext } from './context';
import { useRouter } from 'next/router';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useTranslation } from 'next-i18next';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput/index';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useSystemStore } from '@/web/common/system/useSystemStore';
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';
const recommendTag: TemplateTypeSchemaType = {
typeId: AppTemplateTypeEnum.recommendation,
typeName: i18nT('app:templateMarket.templateTags.Recommendation'),
typeOrder: 0
};
const TemplateMarketModal = ({
defaultType = 'all',
onClose
}: {
defaultType?: TemplateAppType;
onClose: () => void;
}) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { parentId } = useContextSelector(AppListContext, (v) => v);
const router = useRouter();
const { isPc } = useSystem();
const [currentTag, setCurrentTag] = useState<string>(AppTemplateTypeEnum.recommendation);
const [currentAppType, setCurrentAppType] = useState<TemplateAppType>(defaultType);
const [currentSearch, setCurrentSearch] = useState('');
const { data: templateList = [], loading: isLoadingTemplates } = useRequest2(
() => getTemplateMarketItemList({ type: currentAppType }),
{
manual: false,
refreshDeps: [currentAppType]
}
);
const { data: templateTags = [], loading: isLoadingTags } = useRequest2(
() => getTemplateTagList().then((res) => [recommendTag, ...res]),
{
manual: false
}
);
// Batch by tags
const filterTemplateTags = useMemo(() => {
return templateTags
.map((tag) => {
const templates = templateList.filter((template) => template.tags.includes(tag.typeId));
return {
...tag,
templates
};
})
.filter((item) => item.templates.length > 0);
}, [templateList, templateTags]);
const { runAsync: onUseTemplate, loading: isCreating } = useRequest2(
async (template: AppTemplateSchemaType) => {
const templateDetail = await getTemplateMarketItemDetail(template.templateId);
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
}).then((res) => {
webPushTrack.useAppTemplate({
id: res,
name: template.name
});
return res;
});
},
{
onSuccess(id: string) {
onClose();
router.push(`/app/detail?appId=${id}`);
},
successToast: t('common:common.Create Success'),
errorToast: t('common:common.Create Failed')
}
);
const { run: handleScroll } = useRequest2(
async () => {
let firstVisibleTitle: any = null;
filterTemplateTags
.map((type) => type.typeId)
.forEach((type) => {
const element = document.getElementById(type);
if (!element) return;
const elementRect = element.getBoundingClientRect();
if (elementRect.top <= window.innerHeight && elementRect.bottom >= 0) {
if (
!firstVisibleTitle ||
elementRect.top < firstVisibleTitle.getBoundingClientRect().top
) {
firstVisibleTitle = element;
}
}
});
if (firstVisibleTitle) {
setCurrentTag(firstVisibleTitle.id);
}
},
{
throttleWait: 100,
refreshDeps: [filterTemplateTags.length]
}
);
const TemplateCard = useCallback(
({ item }: { item: AppTemplateSchemaType }) => {
const { t } = useTranslation();
return (
<MyBox
key={item.templateId}
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',
'& .buttons': {
display: 'flex'
}
}}
>
<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'}>
<AppTypeTag type={item.type as AppTypeEnum} />
</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>
<Box w={'full'} fontSize={'mini'}>
<Box color={'myGray.500'}>{`by ${item.author || feConfigs.systemTitle}`}</Box>
<Flex
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}
gap={2}
>
{((item.userGuide?.type === 'markdown' && item.userGuide?.content) ||
(item.userGuide?.type === 'link' && item.userGuide?.link)) && (
<UseGuideModal
title={item.name}
iconSrc={item.avatar}
text={item.userGuide?.content}
link={item.userGuide?.link}
>
{({ onClick }) => (
<Button variant={'whiteBase'} h={6} rounded={'sm'} onClick={onClick}>
{t('app:templateMarket.template_guide')}
</Button>
)}
</UseGuideModal>
)}
<Button
variant={'whiteBase'}
h={6}
rounded={'sm'}
onClick={() => onUseTemplate(item)}
>
{t('app:templateMarket.Use')}
</Button>
</Flex>
</Box>
</MyBox>
);
},
[feConfigs.systemTitle, onUseTemplate]
);
const isLoading = isLoadingTags || isLoadingTemplates || isCreating;
return (
<Modal
isOpen={true}
onClose={onClose}
autoFocus={false}
blockScrollOnMount={false}
closeOnOverlayClick={false}
isCentered
>
<ModalOverlay />
<ModalContent
w={['90vw', '80vw']}
maxW={'90vw'}
position={'relative'}
h={['90vh']}
boxShadow={'7'}
overflow={'hidden'}
>
<ModalHeader
display={'flex'}
alignItems={'center'}
py={'10px'}
fontSize={'md'}
fontWeight={'600'}
gap={2}
position={'relative'}
>
<Avatar src={'/imgs/app/templateFill.svg'} w={'2rem'} objectFit={'fill'} />
<Box color={'myGray.900'}>{t('app:template_market')}</Box>
<Box flex={'1'} />
<MySelect<TemplateAppType>
h={'8'}
value={currentAppType}
onChange={(value) => {
setCurrentAppType(value);
}}
bg={'myGray.100'}
minW={'7rem'}
borderRadius={'sm'}
list={[
{ label: t('app:type.All'), value: 'all' },
{ label: t('app:type.Simple bot'), value: AppTypeEnum.simple },
{ label: t('app:type.Workflow bot'), value: AppTypeEnum.workflow },
{ label: t('app:type.Plugin'), value: AppTypeEnum.plugin }
]}
/>
<ModalCloseButton position={'relative'} fontSize={'xs'} top={0} right={0} />
{isPc && (
<Box
width="15rem"
position={'absolute'}
top={'50%'}
left={'50%'}
transform={'translate(-50%,-50%)'}
>
<SearchInput
pl={7}
placeholder={t('app:templateMarket.Search_template')}
value={currentSearch}
onChange={(e) => setCurrentSearch(e.target.value)}
h={8}
bg={'myGray.50'}
maxLength={100}
borderRadius={'sm'}
/>
</Box>
)}
</ModalHeader>
<MyBox isLoading={isLoading} flex={'1 0 0'} h="0">
<ModalBody
h={'100%'}
display={'flex'}
bg={'myGray.100'}
overflow={'auto'}
gap={5}
px={0}
pt={5}
>
{isPc && (
<Flex pl={5} flexDirection={'column'} gap={3}>
{filterTemplateTags.map((item) => {
return (
<Box
key={item.typeId}
cursor={'pointer'}
{...(item.typeId === currentTag && !currentSearch
? {
bg: 'primary.1',
color: 'primary.600'
}
: {
_hover: { bg: 'primary.1' },
color: 'myGray.600'
})}
w={'9.5rem'}
px={4}
py={2}
rounded={'sm'}
fontSize={'sm'}
fontWeight={500}
onClick={() => {
setCurrentTag(item.typeId);
const anchor = document.getElementById(item.typeId);
if (anchor) {
anchor.scrollIntoView({ behavior: 'auto', block: 'start' });
}
}}
>
{t(item.typeName as any)}
</Box>
);
})}
<Box flex={1} />
{feConfigs?.appTemplateCourse && (
<Flex
alignItems={'center'}
cursor={'pointer'}
_hover={{
color: 'primary.600'
}}
py={2}
fontWeight={500}
rounded={'sm'}
fontSize={'sm'}
onClick={() => window.open(feConfigs.appTemplateCourse)}
gap={1}
>
<MyIcon name={'common/upRightArrowLight'} w={'1rem'} />
<Box>{t('common:contribute_app_template')}</Box>
</Flex>
)}
</Flex>
)}
<Box
pl={[3, 0]}
pr={[3, 5]}
pt={1}
flex={'1'}
h={'100%'}
overflow={'auto'}
onScroll={handleScroll}
>
{currentSearch ? (
<>
<Box fontSize={'lg'} color={'myGray.900'} mb={4}>
{t('common:xx_search_result', { key: currentSearch })}
</Box>
{(() => {
const templates = templateList.filter((template) =>
`${template.name}${template.intro}`.includes(currentSearch)
);
if (templates.length > 0) {
return (
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
pb={5}
>
{templates.map((item) => (
<TemplateCard key={item.templateId} item={item} />
))}
</Grid>
);
}
return <EmptyTip text={t('app:template_market_empty_data')} />;
})()}
</>
) : (
<>
{filterTemplateTags.map((item) => {
return (
<Box key={item.typeId}>
<Box
id={item.typeId}
fontSize={['md', 'lg']}
color={'myGray.900'}
mb={4}
fontWeight={500}
>
{t(item.typeName as any)}
</Box>
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
pb={5}
>
{item.templates.map((item) => (
<TemplateCard key={item.templateId} item={item} />
))}
</Grid>
</Box>
);
})}
</>
)}
</Box>
</ModalBody>
</MyBox>
</ModalContent>
</Modal>
);
};
export default TemplateMarketModal;

View File

@@ -299,7 +299,7 @@ const ChatHistorySlider = ({ confirmClearText }: { confirmClearText: string }) =
alignItems={'center'}
cursor={'pointer'}
p={3}
onClick={() => router.push('/app/list')}
onClick={() => router.push('/dashboard/apps')}
>
<IconButton
mr={3}

View File

@@ -64,7 +64,7 @@ const SliderApps = ({ apps, activeAppId }: { apps: AppListItemType[]; activeAppI
px={3}
borderRadius={'md'}
_hover={{ bg: 'myGray.200' }}
onClick={() => router.push('/app/list')}
onClick={() => router.push('/dashboard/apps')}
>
<IconButton
mr={3}

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

@@ -42,15 +42,7 @@ type FormType = {
export type CreateAppType = AppTypeEnum.simple | AppTypeEnum.workflow | AppTypeEnum.plugin;
const CreateModal = ({
onClose,
type,
onOpenTemplateModal
}: {
type: CreateAppType;
onClose: () => void;
onOpenTemplateModal: (type: AppTypeEnum) => void;
}) => {
const CreateModal = ({ onClose, type }: { type: CreateAppType; onClose: () => void }) => {
const { t } = useTranslation();
const router = useRouter();
const { parentId, loadMyApps } = useContextSelector(AppListContext, (v) => v);
@@ -194,14 +186,23 @@ const CreateModal = ({
<Box flex={1} />
{isTemplateMode && (
<Flex
onClick={() => onOpenTemplateModal(type)}
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.more')}
{t('common:core.app.switch_to_template_market')}
<ChevronRightIcon w={4} h={4} />
</Flex>
)}

View File

@@ -51,8 +51,10 @@ const ListItem = () => {
content: t('app:move.hint')
});
const { myApps, loadMyApps, onUpdateApp, setMoveAppId, folderDetail, setSearchKey } =
useContextSelector(AppListContext, (v) => v);
const { myApps, loadMyApps, onUpdateApp, setMoveAppId, folderDetail } = useContextSelector(
AppListContext,
(v) => v
);
const [editedApp, setEditedApp] = useState<EditResourceInfoFormType>();
const [editHttpPlugin, setEditHttpPlugin] = useState<EditHttpPluginProps>();
@@ -132,7 +134,7 @@ const ListItem = () => {
gridTemplateColumns={
folderDetail
? ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
: ['1fr', 'repeat(2,1fr)', 'repeat(2,1fr)', 'repeat(3,1fr)', 'repeat(4,1fr)']
}
gridGap={5}
alignItems={'stretch'}
@@ -176,7 +178,6 @@ const ListItem = () => {
}}
onClick={() => {
if (AppFolderTypeList.includes(app.type)) {
setSearchKey('');
router.push({
query: {
...router.query,
@@ -347,7 +348,9 @@ const ListItem = () => {
}
]
: []),
...(AppFolderTypeList.includes(app.type)
...(app.type === AppTypeEnum.toolSet ||
app.type === AppTypeEnum.folder ||
app.type === AppTypeEnum.httpPlugin
? []
: [
{

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

@@ -33,6 +33,13 @@ const AppTypeTag = ({ type }: { type: AppTypeEnum }) => {
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
});

View File

@@ -19,7 +19,7 @@ const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal'));
type AppListContextType = {
parentId?: string | null;
appType: AppTypeEnum | 'ALL';
appType: AppTypeEnum | 'all';
myApps: AppListItemType[];
loadMyApps: () => Promise<AppListItemType[]>;
isFetchingApps: boolean;
@@ -47,7 +47,7 @@ export const AppListContext = createContext<AppListContextType>({
setMoveAppId: function (value: React.SetStateAction<string | undefined>): void {
throw new Error('Function not implemented.');
},
appType: 'ALL',
appType: 'all',
refetchFolderDetail: async function (): Promise<AppDetailType | null> {
throw new Error('Function not implemented.');
},
@@ -60,7 +60,7 @@ export const AppListContext = createContext<AppListContextType>({
const AppListContextProvider = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation();
const router = useRouter();
const { parentId = null, type = 'ALL' } = router.query as {
const { parentId = null, type = 'all' } = router.query as {
parentId?: string | null;
type: AppTypeEnum;
};
@@ -73,7 +73,7 @@ const AppListContextProvider = ({ children }: { children: ReactNode }) => {
} = useRequest2(
() => {
const formatType = (() => {
if (!type || type === 'ALL') return undefined;
if (!type || type === 'all') return undefined;
if (type === AppTypeEnum.plugin)
return [AppTypeEnum.folder, AppTypeEnum.plugin, AppTypeEnum.httpPlugin];

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);

View File

@@ -162,6 +162,7 @@ const CustomAPIFileInput = () => {
<Flex flexDirection={'column'} h="full">
<Flex justifyContent={'space-between'}>
<FolderPath
forbidLastClick
paths={paths}
onClick={(parentId) => {
const index = paths.findIndex((item) => item.parentId === parentId);

View File

@@ -37,7 +37,7 @@ const FormLayout = ({ children, setPageType, pageType }: Props) => {
const { setLoginStore, feConfigs } = useSystemStore();
const { isPc } = useSystem();
const { lastRoute = '/app/list' } = router.query as { lastRoute: string };
const { lastRoute = '/dashboard/apps' } = router.query as { lastRoute: string };
const state = useRef(getNanoid(8));
const redirectUri = `${location.origin}/login/provider`;