Enhance file upload functionality and system tool integration (#5257)

* Enhance file upload functionality and system tool integration

* Add supplementary documents and optimize the upload interface

* Refactor file plugin types and update upload configurations

* Refactor MinIO configuration variables and clean up API plugin handlers for improved readability and consistency

* File name change

* Refactor SystemTools component layout

* fix i18n

* fix

* fix

* fix
This commit is contained in:
Ctrlz
2025-07-31 11:46:10 +08:00
committed by GitHub
parent e0c21a949c
commit 31c12fdeb9
35 changed files with 867 additions and 69 deletions

View File

@@ -91,3 +91,10 @@ CONFIG_JSON_PATH=
# Signoz
SIGNOZ_BASE_URL=
SIGNOZ_SERVICE_NAME=
# MINIO
S3_ENDPOINT=localhost
S3_PORT=9000
S3_USE_SSL=false
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin

View File

@@ -65,7 +65,8 @@
"request-ip": "^3.3.0",
"sass": "^1.58.3",
"use-context-selector": "^1.4.4",
"zod": "^3.24.2"
"zod": "^3.24.2",
"minio": "^8.0.5"
},
"devDependencies": {
"@svgr/webpack": "^6.5.1",

View File

@@ -2,28 +2,50 @@ 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 React, { useState } from 'react';
import { useTranslation } from 'next-i18next';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { type NodeTemplateListItemType } from '@fastgpt/global/core/workflow/type/node';
import { type PluginGroupSchemaType } from '@fastgpt/service/core/app/plugin/type';
import UseGuideModal from '@/components/common/Modal/UseGuideModal';
const PluginCard = ({
item,
groups
groups,
onDelete
}: {
item: NodeTemplateListItemType;
groups: PluginGroupSchemaType[];
onDelete?: (pluginId: string) => Promise<void>;
}) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const [isHovered, setIsHovered] = useState(false);
const { openConfirm, ConfirmModal } = useConfirm({
type: 'delete',
content: t('common:sure_delete_tool_cannot_undo')
});
const type = groups.reduce<string | undefined>((acc, group) => {
const foundType = group.groupTypes.find((type) => type.typeId === item.templateType);
return foundType ? foundType.typeName : acc;
}, undefined);
const isUploadedPlugin = item.toolSource === 'uploaded';
const handleDelete = async () => {
if (onDelete && item.id) {
await onDelete(item.id);
}
};
const handleDeleteClick = () => {
openConfirm(handleDelete)();
};
return (
<MyBox
key={item.id}
@@ -39,11 +61,31 @@ const PluginCard = ({
position={'relative'}
display={'flex'}
flexDirection={'column'}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
_hover={{
borderColor: 'primary.300',
boxShadow: '1.5'
}}
>
{/* Delete button with centered confirmation modal */}
{isUploadedPlugin && (
<MyIconButton
icon="delete"
position="absolute"
bottom={3}
right={4}
color="blue.500"
aria-label={t('common:Delete')}
zIndex={1}
opacity={isHovered ? 1 : 0}
pointerEvents={isHovered ? 'auto' : 'none'}
onClick={handleDeleteClick}
/>
)}
<ConfirmModal />
<HStack>
<Avatar src={item.avatar} borderRadius={'sm'} w={'1.5rem'} h={'1.5rem'} />
<Box flex={'1 0 0'} color={'myGray.900'} fontWeight={500}>
@@ -102,7 +144,10 @@ const PluginCard = ({
</UseGuideModal>
)}
</Flex>
<Box color={'myGray.500'}>{`by ${item.author || feConfigs.systemTitle}`}</Box>
{/* Hide author info when showing delete button but maintain space */}
<Box color={'myGray.500'} visibility={isUploadedPlugin && isHovered ? 'hidden' : 'visible'}>
{`by ${item.author || feConfigs.systemTitle}`}
</Box>
</Flex>
</MyBox>
);

View File

@@ -0,0 +1,25 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { confirmPresignedUpload } from '@fastgpt/service/common/file/plugin/controller';
type RequestBody = {
objectName: string;
size: string;
};
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
const { objectName, size }: RequestBody = req.body;
// Verify file upload and get access URL
const accessUrl = await confirmPresignedUpload(objectName, size);
return accessUrl;
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: true
}
};

View File

@@ -0,0 +1,38 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import {
generatePresignedUrl,
initFileUploadService
} from '@fastgpt/service/common/file/plugin/controller';
import { NextAPI } from '@/service/middleware/entry';
type RequestBody = {
filename: string;
contentType?: string;
metadata?: Record<string, string>;
maxSize?: number;
expires?: number;
};
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
await initFileUploadService({
bucket: 'fastgpt-uploads',
allowedExtensions: ['.js']
});
const { filename, contentType, metadata, maxSize }: RequestBody = req.body;
if (!filename) {
return Promise.reject('Filename is required');
}
const presignedData = await generatePresignedUrl({
filename,
contentType,
metadata,
maxSize: maxSize || 10 * 1024 * 1024
});
return presignedData;
}
export default NextAPI(handler);

View File

@@ -13,7 +13,7 @@ import { getSystemPlugins } from '@fastgpt/service/core/app/plugin/controller';
export type GetSystemPluginTemplatesBody = {
searchKey?: string;
parentId: ParentIdType;
parentId?: ParentIdType;
};
async function handler(
@@ -35,7 +35,8 @@ async function handler(
templateType: plugin.templateType ?? FlowNodeTemplateTypeEnum.other,
flowNodeType: FlowNodeTypeEnum.tool,
name: parseI18nString(plugin.name, lang),
intro: parseI18nString(plugin.intro ?? '', lang)
intro: parseI18nString(plugin.intro ?? '', lang),
toolSource: plugin.toolSource
}))
.filter((item) => {
if (searchKey) {

View File

@@ -0,0 +1,28 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { deleteSystemTool } from '@fastgpt/service/core/app/tool/api';
import { cleanSystemPluginCache } from '@fastgpt/service/core/app/plugin/controller';
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
const toolId = (req.query.toolId as string) || req.body?.toolId;
if (!toolId) {
return Promise.reject('ToolId is required');
}
const actualToolId = toolId.includes('-') ? toolId.split('-').slice(1).join('-') : toolId;
const result = await deleteSystemTool(actualToolId);
cleanSystemPluginCache();
return result;
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: true
}
};

View File

@@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { uploadSystemTool } from '@fastgpt/service/core/app/tool/api';
import { cleanSystemPluginCache } from '@fastgpt/service/core/app/plugin/controller';
async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
const { url } = req.body;
if (!url) {
return Promise.reject('URL is required');
}
const result = await uploadSystemTool(url);
cleanSystemPluginCache();
return result;
}
export default NextAPI(handler);
export const config = {
api: {
bodyParser: true
}
};

View File

@@ -1,9 +1,29 @@
'use client';
import DashboardContainer from '@/pageComponents/dashboard/Container';
import {
Button,
useDisclosure,
ModalBody,
ModalFooter,
VStack,
HStack,
Link
} from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import MyIconButton from '@fastgpt/web/components/common/Icon/button';
import FileSelector, {
type SelectFileItemType
} from '@/pageComponents/dataset/detail/components/FileSelector';
import PluginCard from '@/pageComponents/dashboard/SystemPlugin/ToolCard';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { getSystemPlugTemplates } from '@/web/core/app/api/plugin';
import {
postUploadFileAndUrl,
postPresignedUrl,
postConfirmUpload,
postS3UploadFile,
postDeletePlugin
} from '@/web/common/file/api';
import { Box, Flex, Grid } from '@chakra-ui/react';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { useRouter } from 'next/router';
@@ -13,18 +33,118 @@ import MyBox from '@fastgpt/web/components/common/MyBox';
import SearchInput from '@fastgpt/web/components/common/Input/SearchInput';
import EmptyTip from '@fastgpt/web/components/common/EmptyTip';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useUserStore } from '@/web/support/user/useUserStore';
import { getDocPath } from '@/web/common/system/doc';
const SystemTools = () => {
const { t } = useTranslation();
const { toast } = useToast();
const router = useRouter();
const { type, pluginGroupId } = router.query as { type?: string; pluginGroupId?: string };
const { isPc } = useSystem();
const { userInfo } = useUserStore();
const isRoot = userInfo?.username === 'root';
const [searchKey, setSearchKey] = useState('');
const [selectFiles, setSelectFiles] = useState<SelectFileItemType[]>([]);
const [deletingPlugins, setDeletingPlugins] = useState<Set<string>>(new Set());
const { data: plugins = [], loading: isLoading } = useRequest2(getSystemPlugTemplates, {
const {
data: plugins = [],
loading: isLoading,
runAsync: refreshPlugins
// refreshAsync: refreshPlugins
} = useRequest2(getSystemPlugTemplates, {
manual: false
});
const { isOpen, onOpen, onClose } = useDisclosure();
const handleCloseUploadModal = () => {
setSelectFiles([]);
onClose();
};
const { run: handlePluginUpload, loading: uploadLoading } = useRequest2(
async () => {
const file = selectFiles[0];
const presignedData = await postPresignedUrl({
filename: file.name,
contentType: file.file.type,
metadata: {
size: String(file.file.size)
}
});
const formData = new FormData();
Object.entries(presignedData.formData).forEach(([key, value]) => {
formData.append(key, value);
});
formData.append('file', file.file);
await postS3UploadFile(presignedData.uploadUrl, formData, (progress) => {
console.log('Upload progress:', progress);
});
const fileUrl = await postConfirmUpload({
objectName: presignedData.objectName,
size: String(file.file.size)
});
await postUploadFileAndUrl(fileUrl);
await refreshPlugins({ parentId: null });
},
{
manual: true,
onSuccess: async () => {
toast({
title: t('common:import_success'),
status: 'success'
});
setSelectFiles([]);
onClose();
// null means all tools
},
onError: (error) => {
toast({
title: t('common:import_failed'),
description: error instanceof Error ? error.message : t('dataset:common.error.unKnow'),
status: 'error'
});
}
}
);
const handlePluginDelete = async (pluginId: string) => {
setDeletingPlugins((prev) => new Set(prev).add(pluginId));
try {
await postDeletePlugin(pluginId);
toast({
title: t('common:delete_success'),
status: 'success'
});
// null means all tools
await refreshPlugins({ parentId: null });
} catch (error) {
Promise.reject(error);
toast({
title: t('common:delete_failed'),
status: 'error'
});
} finally {
setDeletingPlugins((prev) => {
const newSet = new Set(prev);
newSet.delete(pluginId);
return newSet;
});
}
};
const currentPlugins = useMemo(() => {
return plugins
@@ -59,44 +179,119 @@ const SystemTools = () => {
});
return (
<MyBox isLoading={isLoading} h={'100%'}>
<Box p={6} h={'100%'} overflowY={'auto'}>
<Flex alignItems={'center'} justifyContent={'space-between'}>
{isPc ? (
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{t('common:core.module.template.System Plugin')}
</Box>
) : (
MenuIcon
)}
<>
<MyBox isLoading={isLoading} h={'100%'}>
<Box p={6} h={'100%'} overflowY={'auto'}>
<Flex alignItems={'center'} justifyContent={'space-between'}>
{isPc ? (
<Box fontSize={'lg'} color={'myGray.900'} fontWeight={500}>
{t('app:core.module.template.System Tools')}
</Box>
) : (
MenuIcon
)}
<Flex alignItems={'center'}>
<Box flex={'0 0 200px'}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('common:plugin.Search plugin')}
/>
</Box>
{isRoot && <Button onClick={onOpen}>{t('file:common:import_update')}</Button>}
</Flex>
</Flex>
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
py={5}
>
{filterPluginsByGroup.map((item) => (
<PluginCard
key={item.id}
item={item}
groups={pluginGroups}
onDelete={isRoot ? handlePluginDelete : undefined}
/>
))}
</Grid>
{filterPluginsByGroup.length === 0 && <EmptyTip />}
</Box>
</MyBox>
<Box flex={'0 0 200px'}>
<SearchInput
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('common:plugin.Search plugin')}
/>
</Box>
</Flex>
<Grid
gridTemplateColumns={[
'1fr',
'repeat(2,1fr)',
'repeat(2,1fr)',
'repeat(3,1fr)',
'repeat(4,1fr)'
]}
gridGap={4}
alignItems={'stretch'}
py={5}
>
{filterPluginsByGroup.map((item) => (
<PluginCard key={item.id} item={item} groups={pluginGroups} />
))}
</Grid>
{filterPluginsByGroup.length === 0 && <EmptyTip />}
</Box>
</MyBox>
<MyModal
title={t('file:common.upload_system_tools')}
isOpen={isOpen}
onClose={handleCloseUploadModal}
iconSrc="core/app/type/plugin"
iconColor={'primary.600'}
h={'auto'}
>
<ModalBody>
<Flex justifyContent={'flex-end'} mb={3} fontSize={'sm'} fontWeight={500}>
<Link
display={'flex'}
alignItems={'center'}
gap={0.5}
href={getDocPath('/docs/guide/plugins/upload_system_tool/')}
color="primary.600"
target="_blank"
>
<MyIcon name={'book'} w={'18px'} />
{t('common:Instructions')}
</Link>
</Flex>
<FileSelector
maxCount={1}
maxSize="10MB"
fileType=".js"
selectFiles={selectFiles}
setSelectFiles={setSelectFiles}
/>
{/* File render */}
{selectFiles.length > 0 && (
<VStack mt={4} gap={2}>
{selectFiles.map((item, index) => (
<HStack key={index} w={'100%'}>
<MyIcon name={item.icon as any} w={'1rem'} />
<Box color={'myGray.900'}>{item.name}</Box>
<Box fontSize={'xs'} color={'myGray.500'} flex={1}>
{item.size}
</Box>
<MyIconButton
icon="delete"
hoverColor="red.500"
hoverBg="red.50"
onClick={() => {
setSelectFiles(selectFiles.filter((_, i) => i !== index));
}}
/>
</HStack>
))}
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button variant="whiteBase" mr={2} onClick={handleCloseUploadModal}>
{t('common:Close')}
</Button>
<Button
onClick={handlePluginUpload}
isDisabled={selectFiles.length === 0}
isLoading={uploadLoading}
>
{t('common:comfirm_import')}
</Button>
</ModalFooter>
</MyModal>
</>
);
}}
</DashboardContainer>
@@ -108,7 +303,7 @@ export default SystemTools;
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['app']))
...(await serviceSideProps(content, ['app', 'file']))
}
};
}

View File

@@ -1,6 +1,7 @@
import { GET, POST } from '@/web/common/api/request';
import { DELETE, GET, POST } from '@/web/common/api/request';
import type { UploadImgProps } from '@fastgpt/global/common/file/api.d';
import { type AxiosProgressEvent } from 'axios';
import type { PresignedUrlResponse } from '@fastgpt/service/common/file/plugin/config';
export const postUploadImg = (e: UploadImgProps) => POST<string>('/common/file/uploadImage', e);
@@ -18,3 +19,36 @@ export const postUploadFiles = (
'Content-Type': 'multipart/form-data; charset=utf-8'
}
});
export const postS3UploadFile = (
postURL: string,
form: FormData,
onUploadProgress: (progressEvent: AxiosProgressEvent) => void
) =>
POST(postURL, form, {
timeout: 600000,
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress
});
export const postPresignedUrl = (data: {
filename: string;
contentType?: string;
metadata?: Record<string, string>;
maxSize?: number;
}) => POST<PresignedUrlResponse>('/common/file/plugin/presignedUrl', data);
export const postConfirmUpload = (data: { objectName: string; size: string }) =>
POST<string>('/common/file/plugin/confirmUpload', data);
export const postUploadFileAndUrl = (url: string) =>
POST<void>('/plugin/upload', {
url: url
});
export const postDeletePlugin = (toolId: string) =>
DELETE<void>('/plugin/delete', {
toolId
});