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

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,5 +1,14 @@
{
"title": "系统插件",
"description": "介绍如何使用和提交系统插件,以及各插件的填写说明",
"pages": ["dev_system_tool","how_to_submit_system_plugin","searxng_plugin_guide","google_search_plugin_guide","bing_search_plugin","doc2x_plugin_guide"]
"pages": [
"dev_system_tool",
"how_to_submit_system_plugin",
"upload_system_tool",
"searxng_plugin_guide",
"google_search_plugin_guide",
"bing_search_plugin",
"doc2x_plugin_guide",
"deepseek_plugin_guide"
]
}

View File

@@ -0,0 +1,123 @@
---
title: 如何在线上传系统工具
description: FastGPT 系统工具在线上传指南
---
> 从 FastGPT 4.10.0 版本开始,系统管理员可以通过 Web 界面直接上传和更新系统工具,无需重新部署服务
## 权限要求
⚠️ **重要提示**:只有 **root 用户** 才能使用在线上传系统工具功能。
- 确保您已使用 `root` 账户登录 FastGPT
- 普通用户无法看到"导入/更新"按钮和删除功能
## 支持的文件格式
- **文件类型**`.js` 文件
- **文件大小**:最大 10MB
- **文件数量**:每次只能上传一个文件
## 上传步骤
### 1. 进入系统工具页面
1. 登录 FastGPT 管理后台
2. 导航到:**工作台** → **系统工具**
3. 确认页面右上角显示"导入/更新"按钮(只有 root 用户可见)
![](/imgs/plugins/entry.png)
### 2. 准备工具文件
在上传之前,请确保您的 `.js` 文件是从 fastgpt-plugin 项目中通过 `bun run build` 命令打包后的 dist/tools/built-in 文件夹下得到的
![](/imgs/plugins/file.png)
### 3. 执行上传
1. 点击 **"导入/更新"** 按钮
2. 在弹出的对话框中,点击文件选择区域
3. 选择您准备好的 `.js` 工具文件
4. 确认文件信息无误后,点击 **"确认导入"**
### 4. 上传过程
- 上传成功后会显示成功提示
- 页面自动刷新,新工具会出现在工具列表中
## 功能特点
### 工具管理
- **查看工具**:所有用户都可以查看已安装的系统工具
- **上传工具**:仅 root 用户可以上传新工具或更新现有工具
- **删除工具**:仅 root 用户可以删除已上传的工具
### 工具类型识别
系统会根据工具的配置自动识别工具类型:
- 🔧 **工具 (tools)**
- 🔍 **搜索 (search)**
- 🎨 **多模态 (multimodal)**
- 💬 **通讯 (communication)**
- 📦 **其他 (other)**
## 常见问题
### Q: 上传失败,提示"文件内容存在错误"
**可能原因:**
- fastgpt-plugin 项目不是最新的,导致打包的 `.js` 文件缺少正确的内容
- 工具配置格式不正确
**解决方案:**
1. 拉取最新的 fastgpt-plugin 项目重新进行 `bun run build` 获得打包后的 `.js` 文件
2. 检查本地插件运行是否成功
### Q: 无法看到"导入/更新"按钮
**原因:** 当前用户不是 root 用户
**解决方案:** 使用 root 账户重新登录
### Q: 文件上传超时
**可能原因:**
- 文件过大(超过 10MB
- 网络连接不稳定
**解决方案:**
1. 确认文件大小在限制范围内
2. 检查网络连接
3. 尝试重新上传
## 最佳实践
### 上传前检查
1. **代码测试**:在本地环境测试工具功能
2. **格式验证**:确保符合 FastGPT 工具规范
3. **文件大小**:保持文件在合理大小范围内
### 版本管理
- 建议为工具添加版本号注释
- 更新工具时,先备份原有版本
- 记录更新日志和功能变更
### 安全考虑
- 仅上传来源可信的工具文件
- 避免包含敏感信息或凭据
- 定期审查已安装的工具
### 存储方式
- 工具文件存储在 MinIO 中
- 工具元数据保存在 MongoDB 中
---
通过在线上传功能,您可以快速部署和管理系统工具,提高 FastGPT 的扩展性和灵活性。如遇到问题,请参考上述常见问题或联系技术支持。

View File

@@ -55,6 +55,9 @@ export type SystemPluginTemplateItemType = WorkflowTemplateType & {
// Admin config
inputList?: FlowNodeInputItemType['inputList'];
hasSystemSecret?: boolean;
// Plugin source type
toolSource?: 'uploaded' | 'built-in';
};
export type SystemPluginTemplateListItemType = Omit<

View File

@@ -124,6 +124,7 @@ export type NodeTemplateListItemType = {
instructions?: string; // 使用说明
courseUrl?: string; // 教程链接
sourceMember?: SourceMember;
toolSource?: 'uploaded' | 'built-in'; // Plugin source type
};
export type NodeTemplateListType = {

View File

@@ -0,0 +1,56 @@
import { mimeTypes } from './utils';
export enum FilePluginTypeEnum {
tool = 'tool'
}
export enum PluginPathEnum {
tools = 'plugin/tools'
}
export const PLUGIN_TYPE_TO_PATH_MAP: Record<FilePluginTypeEnum, PluginPathEnum> = {
[FilePluginTypeEnum.tool]: PluginPathEnum.tools
};
export type UploadFileConfig = {
maxFileSize: number;
allowedExtensions?: string[];
bucket: string;
};
export const defaultUploadConfig: UploadFileConfig = {
maxFileSize: process.env.UPLOAD_MAX_FILE_SIZE
? parseInt(process.env.UPLOAD_MAX_FILE_SIZE)
: 10 * 1024 * 1024,
bucket: process.env.MINIO_UPLOAD_BUCKET || 'fastgpt-uploads',
allowedExtensions: Object.keys(mimeTypes)
};
export type FileMetadata = {
fileId: string;
originalFilename: string;
contentType: string;
size: number;
uploadTime: Date;
accessUrl: string;
};
export type PresignedUrlInput = {
filename: string;
pluginType?: FilePluginTypeEnum;
contentType?: string;
metadata?: Record<string, string>;
maxSize?: number;
};
export type PresignedUrlResponse = {
fileId: string;
objectName: string;
uploadUrl: string;
formData: Record<string, string>;
};
export type FileUploadInput = {
buffer: Buffer;
filename: string;
};

View File

@@ -0,0 +1,107 @@
import { randomBytes } from 'crypto';
import {
defaultUploadConfig,
type UploadFileConfig,
type PresignedUrlInput,
type PresignedUrlResponse,
FilePluginTypeEnum,
PLUGIN_TYPE_TO_PATH_MAP
} from './config';
import { addLog } from '../../system/log';
import { ensureBucket } from '../../minio/init';
import { connectionMinio } from '../../minio/index';
import { inferContentType } from './utils';
let globalConfig: UploadFileConfig = defaultUploadConfig;
export const initFileUploadService = async (config?: Partial<UploadFileConfig>) => {
globalConfig = { ...defaultUploadConfig, ...config };
try {
addLog.info(`Initializing upload bucket: ${globalConfig.bucket}`);
await ensureBucket(globalConfig.bucket, true);
addLog.info(`Upload bucket initialized successfully: ${globalConfig.bucket}`);
return true;
} catch (error) {
addLog.error(`Failed to initialize upload bucket: ${globalConfig.bucket}`, error);
throw error;
}
};
const generateFileId = (): string => {
return randomBytes(16).toString('hex');
};
export const generateDownloadUrl = (objectName: string, config: UploadFileConfig): string => {
const pathParts = objectName.split('/');
const encodedParts = pathParts.map((part) => encodeURIComponent(part));
const encodedObjectName = encodedParts.join('/');
return `${config.bucket}/${encodedObjectName}`;
};
//Generate a pre-signed URL for direct file upload
export const generatePresignedUrl = async (
input: PresignedUrlInput
): Promise<PresignedUrlResponse> => {
const currentConfig = { ...globalConfig };
const fileId = generateFileId();
const pluginType = input.pluginType || FilePluginTypeEnum.tool;
const pluginPath = PLUGIN_TYPE_TO_PATH_MAP[pluginType];
const objectName = `${pluginPath}/${fileId}/${input.filename}`;
const contentType = input.contentType || inferContentType(input.filename);
const maxSize = input.maxSize || currentConfig.maxFileSize;
try {
const policy = connectionMinio.newPostPolicy();
policy.setBucket(currentConfig.bucket);
policy.setKey(objectName);
policy.setContentType(contentType);
policy.setContentLengthRange(1, maxSize);
policy.setExpires(new Date(Date.now() + 10 * 60 * 1000));
const metadata = {
'original-filename': encodeURIComponent(input.filename),
'upload-time': new Date().toISOString(),
'file-id': fileId,
...input.metadata
};
policy.setUserMetaData(metadata);
const { postURL, formData } = await connectionMinio.presignedPostPolicy(policy);
const response: PresignedUrlResponse = {
fileId,
objectName,
uploadUrl: postURL,
formData
};
return response;
} catch (error) {
addLog.error('Failed to generate presigned URL', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return Promise.reject(`Failed to generate presigned URL: ${errorMessage}`);
}
};
//Confirm the presigned URL upload is complete and save the file information to MongoDB
export const confirmPresignedUpload = async (objectName: string, size: string): Promise<string> => {
try {
const currentConfig = { ...globalConfig };
const stat = await connectionMinio.statObject(currentConfig.bucket, objectName);
if (stat.size !== Number(size)) {
addLog.error(`File size mismatch. Expected: ${size}, Actual: ${stat.size}`);
return Promise.reject(`File size mismatch. Expected: ${size}, Actual: ${stat.size}`);
}
const accessUrl = generateDownloadUrl(objectName, currentConfig);
return accessUrl;
} catch (error) {
addLog.error('Failed to confirm presigned upload', error);
return Promise.reject(`Failed to confirm presigned upload: ${error}`);
}
};

View File

@@ -0,0 +1,10 @@
import * as path from 'path';
export const mimeTypes: Record<string, string> = {
'.js': 'application/javascript'
};
export const inferContentType = (filename: string): string => {
const ext = path.extname(filename).toLowerCase();
return mimeTypes[ext] || 'application/octet-stream';
};

View File

@@ -0,0 +1,27 @@
import { Client } from 'minio';
export * from 'minio';
export { Client };
export const S3_ENDPOINT = process.env.S3_ENDPOINT || 'localhost';
export const S3_PORT = process.env.S3_PORT ? parseInt(process.env.S3_PORT) : 9000;
export const S3_USE_SSL = process.env.S3_USE_SSL === 'true';
export const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY || 'minioadmin';
export const S3_SECRET_KEY = process.env.S3_SECRET_KEY || 'minioadmin';
export const connectionMinio = (() => {
if (!global.minioClient) {
global.minioClient = new Client({
endPoint: S3_ENDPOINT,
port: S3_PORT,
useSSL: S3_USE_SSL,
accessKey: S3_ACCESS_KEY,
secretKey: S3_SECRET_KEY
});
}
return global.minioClient;
})();
export const getMinioClient = () => connectionMinio;
export default connectionMinio;

View File

@@ -0,0 +1,39 @@
import { connectionMinio } from './index';
import { addLog } from '../system/log';
import { retryFn } from '@fastgpt/global/common/system/utils';
export const ensureBucket = async (bucketName: string, isPublic: boolean = false) => {
return retryFn(async () => {
try {
const bucketExists = await connectionMinio.bucketExists(bucketName);
if (!bucketExists) {
addLog.info(`Creating bucket: ${bucketName}`);
await connectionMinio.makeBucket(bucketName);
}
if (isPublic) {
// Set public read policy
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${bucketName}/*`]
}
]
};
await connectionMinio.setBucketPolicy(bucketName, JSON.stringify(policy));
addLog.info(`Set public read policy for bucket: ${bucketName}`);
}
return true;
} catch (error) {
addLog.error(`Failed to ensure bucket ${bucketName}:`, error);
throw error;
}
}, 3);
};

View File

@@ -0,0 +1,5 @@
import type { Client } from 'minio';
declare global {
var minioClient: Client;
}

View File

@@ -391,7 +391,7 @@ function getCachedSystemPlugins() {
return global.systemPlugins_cache;
}
const cleanSystemPluginCache = () => {
export const cleanSystemPluginCache = () => {
global.systemPlugins_cache = undefined;
};
@@ -439,22 +439,10 @@ export const getSystemPlugins = async (): Promise<SystemPluginTemplateItemType[]
const inputs = versionList[0]?.inputs;
return {
isActive: item.isActive,
id: item.id,
parentId: item.parentId,
...item,
isFolder: tools.some((tool) => tool.parentId === item.id),
name: item.name,
avatar: item.avatar,
intro: item.intro,
author: item.author,
courseUrl: item.courseUrl,
showStatus: true,
weight: item.weight,
templateType: item.templateType,
originCost: item.originCost,
currentCost: item.currentCost,
hasTokenFee: item.hasTokenFee,
pluginOrder: item.pluginOrder,
toolSource: item.toolSource || 'built-in',
workflow: {
nodes: [],

View File

@@ -29,6 +29,26 @@ export async function getSystemToolList() {
return Promise.reject(res.body);
}
export async function deleteSystemTool(toolId: string) {
const res = await client.tool.delete({ body: { toolId } });
if (res.status === 200) {
return res.body;
}
return Promise.reject(res.body);
}
export async function uploadSystemTool(url: string) {
const res = await client.tool.upload({ body: { url } });
if (res.status === 200) {
return res.body;
}
return Promise.reject(res.body);
}
const runToolInstance = new RunToolWithStream({
baseUrl: BASE_URL,
token: TOKEN

View File

@@ -52,7 +52,8 @@
"tiktoken": "1.0.17",
"tunnel": "^0.0.6",
"turndown": "^7.1.2",
"winston": "^3.17.0"
"winston": "^3.17.0",
"minio": "^8.0.5"
},
"devDependencies": {
"@types/cookie": "^0.5.2",

View File

@@ -86,7 +86,7 @@ export const workflowNodeTemplateList: {
export const defaultGroup: PluginGroupSchemaType = {
groupId: 'systemPlugin',
groupAvatar: 'core/app/type/pluginLight',
groupName: i18nT('common:core.module.template.System Plugin'),
groupName: i18nT('app:core.module.template.System Tools'),
groupOrder: 0,
groupTypes: systemPluginTemplateList
};

View File

@@ -43,6 +43,7 @@
"confirm_delete_folder_tip": "Confirm to delete this folder? All apps and corresponding conversation records under it will be deleted. Please confirm!",
"copy_one_app": "Create Duplicate",
"core.app.QG.Switch": "Enable guess what you want to ask",
"core.module.template.System Tools": "System Tools",
"core.dataset.import.Custom prompt": "Custom Prompt",
"create_by_curl": "By CURL",
"create_by_template": "By template",

View File

@@ -659,7 +659,6 @@
"core.module.template.AI support tool tip": "Models that support function calls can better use tool calls.",
"core.module.template.Basic Node": "Basic",
"core.module.template.Query extension": "Question Optimization",
"core.module.template.System Plugin": "System Plugin",
"core.module.template.System input module": "System Input",
"core.module.template.Team app": "Team",
"core.module.template.UnKnow Module": "Unknown Module",
@@ -1217,6 +1216,7 @@
"support.wallet.usage.Total points": "AI Points Consumption",
"support.wallet.usage.Usage Detail": "Usage Details",
"support.wallet.usage.Whisper": "Voice Input",
"sure_delete_tool_cannot_undo": "Are you sure to delete the tool? \nThis operation cannot be withdrawn",
"sync_link": "Sync Link",
"sync_success": "Synced Successfully",
"system.Concat us": "Contact Us",

View File

@@ -13,11 +13,13 @@
"Only_support_uploading_one_image": "Only support uploading one image",
"Please select the image to upload": "Please select the image to upload",
"Please wait for all files to upload": "Please wait for all files to be uploaded to complete",
"common.upload_system_tools": "Upload system tools",
"bucket_chat": "Conversation Files",
"bucket_file": "Dataset Documents",
"click_to_view_raw_source": "Click to View Original Source",
"common.Some images failed to process": "Some images failed to process",
"common.dataset_data_input_image_support_format": "Support .jpg, .jpeg, .png, .gif, .webp formats",
"common.import_update": "Import/Update",
"count.core.dataset.collection.Create Success": "{{count}} picture successfully imported",
"delete_image": "Delete pictures",
"file_name": "Filename",

View File

@@ -43,6 +43,7 @@
"confirm_delete_folder_tip": "确认删除该文件夹?将会删除它下面所有应用及对应的聊天记录,请确认!",
"copy_one_app": "创建副本",
"core.app.QG.Switch": "启用猜你想问",
"core.module.template.System Tools": "系统工具",
"core.dataset.import.Custom prompt": "自定义提示词",
"create_by_curl": "从 CURL 创建",
"create_by_template": "从模板创建",

View File

@@ -659,7 +659,6 @@
"core.module.template.AI support tool tip": "支持函数调用的模型,可以更好的使用工具调用。",
"core.module.template.Basic Node": "基础功能",
"core.module.template.Query extension": "问题优化",
"core.module.template.System Plugin": "系统插件",
"core.module.template.System input module": "系统输入",
"core.module.template.Team app": "团队应用",
"core.module.template.UnKnow Module": "未知模块",
@@ -1218,6 +1217,7 @@
"support.wallet.usage.Total points": "AI 积分消耗",
"support.wallet.usage.Usage Detail": "使用详情",
"support.wallet.usage.Whisper": "语音输入",
"sure_delete_tool_cannot_undo": "是否确认删除该工具?该操作无法撤回",
"sync_link": "同步链接",
"sync_success": "同步成功",
"system.Concat us": "联系我们",

View File

@@ -18,6 +18,8 @@
"click_to_view_raw_source": "点击查看来源",
"common.Some images failed to process": "部分图片处理失败",
"common.dataset_data_input_image_support_format": "支持 .jpg, .jpeg, .png, .gif, .webp 格式",
"common.import_update": "导入/更新",
"common.upload_system_tools": "上传系统工具",
"count.core.dataset.collection.Create Success": "成功导入 {{count}} 张图片",
"delete_image": "删除图片",
"file_name": "文件名",

View File

@@ -44,6 +44,7 @@
"copy_one_app": "建立副本",
"core.app.QG.Switch": "啟用猜你想問",
"core.dataset.import.Custom prompt": "自訂提示詞",
"core.module.template.System Tools": "系統工具",
"create_by_curl": "從 CURL 建立",
"create_by_template": "從範本建立",
"create_copy_success": "建立副本成功",

View File

@@ -659,7 +659,6 @@
"core.module.template.AI support tool tip": "支援函式呼叫的模型可以更好地使用工具呼叫。",
"core.module.template.Basic Node": "基本功能",
"core.module.template.Query extension": "問題最佳化",
"core.module.template.System Plugin": "系統外掛",
"core.module.template.System input module": "系統輸入模組",
"core.module.template.Team app": "團隊應用程式",
"core.module.template.UnKnow Module": "未知模組",
@@ -1217,6 +1216,7 @@
"support.wallet.usage.Total points": "AI 點數消耗",
"support.wallet.usage.Usage Detail": "使用詳細資訊",
"support.wallet.usage.Whisper": "語音輸入",
"sure_delete_tool_cannot_undo": "是否確認刪除該工具?\n該操作無法撤回",
"sync_link": "同步連結",
"sync_success": "同步成功",
"system.Concat us": "聯絡我們",

View File

@@ -13,11 +13,13 @@
"Only_support_uploading_one_image": "僅支持上傳一張圖片",
"Please select the image to upload": "請選擇要上傳的圖片",
"Please wait for all files to upload": "請等待所有文件上傳完成",
"common.upload_system_tools": "上傳系統工具",
"bucket_chat": "對話檔案",
"bucket_file": "知識庫檔案",
"click_to_view_raw_source": "點選檢視原始來源",
"common.Some images failed to process": "部分圖片處理失敗",
"common.dataset_data_input_image_support_format": "支持 .jpg, .jpeg, .png, .gif, .webp 格式",
"common.import_update": "導入/更新",
"count.core.dataset.collection.Create Success": "成功導入 {{count}} 張圖片",
"delete_image": "刪除圖片",
"file_name": "檔案名稱",

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,17 +179,18 @@ 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')}
{t('app:core.module.template.System Tools')}
</Box>
) : (
MenuIcon
)}
<Flex alignItems={'center'}>
<Box flex={'0 0 200px'}>
<SearchInput
value={searchKey}
@@ -77,6 +198,8 @@ const SystemTools = () => {
placeholder={t('common:plugin.Search plugin')}
/>
</Box>
{isRoot && <Button onClick={onOpen}>{t('file:common:import_update')}</Button>}
</Flex>
</Flex>
<Grid
gridTemplateColumns={[
@@ -91,12 +214,84 @@ const SystemTools = () => {
py={5}
>
{filterPluginsByGroup.map((item) => (
<PluginCard key={item.id} item={item} groups={pluginGroups} />
<PluginCard
key={item.id}
item={item}
groups={pluginGroups}
onDelete={isRoot ? handlePluginDelete : undefined}
/>
))}
</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
});